design-spine 0.1.0 → 0.2.0

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,1575 @@
1
+ const state = {
2
+ run: null,
3
+ review: {},
4
+ critique: {},
5
+ comparison: {},
6
+ comparisonLoading: false,
7
+ comparisonFailed: false,
8
+ comparisonGeneration: 0,
9
+ comparedAt: null,
10
+ threshold: 0.002,
11
+ onlyChanged: false,
12
+ page: 0,
13
+ pageSize: 48,
14
+ viewerMode: 'current',
15
+ onlyFlagged: false,
16
+ onlyUnreviewed: false,
17
+ search: '',
18
+ status: '',
19
+ evidence: '',
20
+ viewport: '',
21
+ theme: '',
22
+ focus: null,
23
+ drafts: new Map(),
24
+ pending: new Map(),
25
+ failures: new Map(),
26
+ loading: true,
27
+ viewer: null,
28
+ returnFocus: null,
29
+ }
30
+ const selectedRun = () => new URL(location.href).searchParams.get('run')
31
+ const apiPath = (path) => {
32
+ const url = new URL(path, location.origin)
33
+ if (selectedRun()) url.searchParams.set('run', selectedRun())
34
+ return url.pathname + url.search
35
+ }
36
+ const $ = (s) => document.querySelector(s)
37
+ const el = (tag, cls, text) => {
38
+ const e = document.createElement(tag)
39
+ if (cls) e.className = cls
40
+ if (text != null) e.textContent = text
41
+ return e
42
+ }
43
+ const decision = (t) => state.review[t.id]?.decision || null
44
+ const comparisonLabels = {
45
+ new: 'New · no baseline',
46
+ changed: 'Changed',
47
+ unchanged: 'Within threshold',
48
+ 'size-changed': 'Dimensions changed',
49
+ 'no-capture': 'Comparison: no capture',
50
+ unavailable: 'Comparison unavailable',
51
+ }
52
+
53
+ function flags(t) {
54
+ const f = []
55
+ if (t.accessibility?.error) f.push(t.accessibility.error)
56
+ if (t.accessibility?.violations.length)
57
+ f.push(t.accessibility.violations.length + ' automated accessibility findings')
58
+ if (t.stability === 'unstable') f.push('Capture did not stabilize')
59
+ if (t.error) f.push('Capture failed: ' + t.error)
60
+ if (t.status === 0 && !t.error) f.push('No response')
61
+ if (t.status >= 400) f.push('HTTP ' + t.status)
62
+ if (t.overflowX) f.push('Horizontal overflow')
63
+ if (t.blankRegions.count)
64
+ f.push(t.blankRegions.count + ' blank region' + (t.blankRegions.count > 1 ? 's' : ''))
65
+ if (t.brokenImages.length)
66
+ f.push(t.brokenImages.length + ' broken image' + (t.brokenImages.length > 1 ? 's' : ''))
67
+ if (t.consoleErrors.length)
68
+ f.push(t.consoleErrors.length + ' console error' + (t.consoleErrors.length > 1 ? 's' : ''))
69
+ if (t.pageErrors.length)
70
+ f.push(t.pageErrors.length + ' page error' + (t.pageErrors.length > 1 ? 's' : ''))
71
+ if (t.redirectedTo) f.push('Redirected to ' + t.redirectedTo)
72
+ return f
73
+ }
74
+ const isFlagged = (t) => flags(t).length > 0
75
+ function visibleTiles() {
76
+ const routeOrder = new Map(
77
+ [...new Set((state.run?.tiles || []).map((t) => t.route))].map((route, index) => [
78
+ route,
79
+ index,
80
+ ]),
81
+ )
82
+ return (state.run?.tiles || [])
83
+ .filter(
84
+ (t) =>
85
+ (!state.onlyChanged ||
86
+ ['new', 'changed', 'size-changed'].includes(state.comparison[t.id]?.status)) &&
87
+ (!state.onlyFlagged || isFlagged(t)) &&
88
+ (!state.onlyUnreviewed || !decision(t)) &&
89
+ (!state.evidence || state.comparison[t.id]?.status === state.evidence) &&
90
+ (!state.status || decision(t) === state.status) &&
91
+ (!state.viewport || String(t.viewport) === state.viewport) &&
92
+ (!state.theme || t.theme === state.theme) &&
93
+ (t.route + ' ' + t.state).toLowerCase().includes(state.search),
94
+ )
95
+ .sort(
96
+ (a, b) =>
97
+ routeOrder.get(a.route) - routeOrder.get(b.route) ||
98
+ a.state.localeCompare(b.state) ||
99
+ a.viewport - b.viewport ||
100
+ (a.theme === b.theme ? 0 : a.theme === 'light' ? -1 : 1),
101
+ )
102
+ }
103
+ function showEmpty(title, text, action, callback) {
104
+ const box = el('div', 'empty')
105
+ box.append(el('h2', null, title), el('p', null, text))
106
+ if (action) {
107
+ const button = el('button', 'button', action)
108
+ button.addEventListener('click', callback)
109
+ box.append(button)
110
+ }
111
+ $('#cards').replaceChildren(box)
112
+ }
113
+ async function load() {
114
+ state.loading = true
115
+ state.run = null
116
+ state.review = {}
117
+ state.critique = {}
118
+ state.comparison = {}
119
+ $('#cards').setAttribute('aria-busy', 'true')
120
+ $('#progress-label').textContent = 'Loading review progress'
121
+ $('#percent').textContent = '…'
122
+ updateControls()
123
+ showEmpty('Loading your captures…', 'Reading this run’s screenshots and review decisions.')
124
+ try {
125
+ const response = await fetch(apiPath('/api/run?compare=false'))
126
+ if (!response.ok) throw new Error('The review server returned HTTP ' + response.status + '.')
127
+ const data = await response.json()
128
+ if (!data.run || !Array.isArray(data.run.tiles))
129
+ throw new Error('This run does not contain valid capture data.')
130
+ state.comparison = data.comparison?.tiles || {}
131
+ state.threshold = data.comparison?.threshold ?? 0.002
132
+ state.run = data.run
133
+ state.review = data.review?.tiles || {}
134
+ state.critique = data.critique?.tiles || {}
135
+ if (!selectedRun() && data.runKey) {
136
+ const url = new URL(location.href)
137
+ url.searchParams.set('run', data.runKey)
138
+ history.replaceState(null, '', url)
139
+ }
140
+ renderCoverage()
141
+ loadHistory()
142
+ $('#runid').textContent = data.run.id
143
+ $('#baseurl').textContent = data.run.baseUrl
144
+ const nav = $('#route-nav')
145
+ nav.replaceChildren()
146
+ for (const [index, route] of [...new Set(data.run.tiles.map((t) => t.route))].entries()) {
147
+ const link = el('a', null, route)
148
+ link.href = '#route-' + index
149
+ link.dataset.route = route
150
+ link.append(el('span'))
151
+ link.addEventListener('click', (event) => {
152
+ event.preventDefault()
153
+ clearFilters()
154
+ const position = visibleTiles().findIndex((t) => t.route === route)
155
+ state.page = Math.floor(Math.max(0, position) / state.pageSize)
156
+ render()
157
+ const section = $('#route-' + index)
158
+ if (section) {
159
+ section.scrollIntoView({ block: 'start' })
160
+ section.querySelector('h2').focus({ preventScroll: true })
161
+ }
162
+ })
163
+ nav.append(link)
164
+ }
165
+ const select = $('#viewport-filter')
166
+ select.replaceChildren(new Option('All widths', ''))
167
+ for (const width of [...new Set(data.run.tiles.map((t) => t.viewport))].sort((a, b) => a - b))
168
+ select.add(new Option(width + 'px', String(width)))
169
+ state.loading = false
170
+ render()
171
+ loadComparison(data.comparison)
172
+ const linked = new URL(location.href).searchParams.get('tile')
173
+ const linkedTile = data.run.tiles.find((t) => t.id === linked)
174
+ if (linkedTile) openBox(linkedTile)
175
+ } catch (error) {
176
+ state.loading = false
177
+ $('#runid').textContent = 'Run unavailable'
178
+ $('#results').textContent = 'Unable to load captures'
179
+ $('#progress-label').textContent = 'Review progress unavailable'
180
+ $('#percent').textContent = 'n/a'
181
+ showEmpty(
182
+ 'Could not load this run.',
183
+ error.message + ' Check that the review server is running, then retry.',
184
+ 'Retry loading',
185
+ load,
186
+ )
187
+ } finally {
188
+ $('#cards').setAttribute('aria-busy', 'false')
189
+ updateControls()
190
+ }
191
+ }
192
+ async function loadComparison(provided) {
193
+ const generation = ++state.comparisonGeneration
194
+ state.comparisonLoading = true
195
+ state.comparisonFailed = false
196
+ $('#comparison-status').textContent =
197
+ 'Comparing captures with local baselines… You can continue reviewing.'
198
+ for (const label of document.querySelectorAll('.tile .comparison-status'))
199
+ label.textContent = 'Comparing…'
200
+ updateControls()
201
+ try {
202
+ let comparison = provided
203
+ if (!comparison) {
204
+ const response = await fetch(apiPath('/api/comparison'))
205
+ if (!response.ok) throw new Error('Comparison unavailable')
206
+ comparison = await response.json()
207
+ }
208
+ if (!comparison?.tiles || typeof comparison.threshold !== 'number')
209
+ throw new Error('Invalid comparison evidence')
210
+ if (generation !== state.comparisonGeneration) return
211
+ state.comparison = comparison.tiles
212
+ state.threshold = comparison.threshold
213
+ state.comparedAt = comparison.comparedAt || null
214
+ $('#comparison-status').textContent =
215
+ 'Comparison ready · threshold ' +
216
+ (state.threshold * 100).toFixed(2) +
217
+ '% · Baselines change only through the approve command.'
218
+ } catch {
219
+ if (generation !== state.comparisonGeneration) return
220
+ state.comparisonFailed = true
221
+ const box = $('#comparison-status')
222
+ box.replaceChildren(
223
+ el(
224
+ 'span',
225
+ null,
226
+ 'Comparison could not load. Your captures and review notes are still available.',
227
+ ),
228
+ )
229
+ const retry = el('button', 'button', 'Retry comparison')
230
+ retry.addEventListener('click', () => loadComparison())
231
+ box.append(retry)
232
+ } finally {
233
+ if (generation === state.comparisonGeneration) {
234
+ state.comparisonLoading = false
235
+ if (state.onlyChanged || state.evidence) render()
236
+ updateControls()
237
+ for (const card of document.querySelectorAll('.tile'))
238
+ card.querySelector('.comparison-status').textContent =
239
+ comparisonLabels[state.comparison[card.dataset.id]?.status] ||
240
+ (state.comparisonFailed ? 'Comparison unavailable' : 'Not compared')
241
+ if ($('#lightbox').open) {
242
+ if (viewerTiles().some((t) => t.id === state.viewer)) updateViewer()
243
+ else $('#lightbox').close()
244
+ }
245
+ }
246
+ }
247
+ }
248
+ function updateControls() {
249
+ updateCoverageDecisions()
250
+ const dirty = state.drafts.size || state.pending.size || state.failures.size
251
+ for (const control of document.querySelectorAll(
252
+ '.toolbar input, .toolbar select, .toolbar button',
253
+ ))
254
+ control.disabled = !state.run || state.loading
255
+ $('#export').disabled = !state.run || state.loading || !!dirty || state.comparisonLoading
256
+ $('#report').disabled = $('#export').disabled
257
+ $('#run-select').disabled = !state.run || state.loading || !!dirty
258
+ $('#f-changed').disabled =
259
+ !state.run || state.loading || state.comparisonLoading || state.comparisonFailed
260
+ $('#comparison-filter').disabled = $('#f-changed').disabled
261
+ updateViewerControls()
262
+ $('#export').title = state.comparisonLoading
263
+ ? 'Waiting for comparison evidence.'
264
+ : dirty
265
+ ? 'Save or retry your changes before exporting.'
266
+ : 'Download the full run and confirmed review decisions.'
267
+ $('#next-pending').disabled = !visibleTiles().some((t) => !decision(t)) || state.loading
268
+ }
269
+ async function loadHistory() {
270
+ const select = $('#run-select')
271
+ select.replaceChildren(new Option(state.run.id, selectedRun() || ''))
272
+ try {
273
+ const response = await fetch('/api/runs')
274
+ if (!response.ok) return
275
+ const runs = await response.json()
276
+ if (!Array.isArray(runs)) return
277
+ const current = selectedRun()
278
+ select.replaceChildren()
279
+ if (!current) select.add(new Option(state.run.id + ' · opened run', ''))
280
+ for (const run of runs) select.add(new Option(run.id + ' · ' + run.tiles + ' captures', run.id))
281
+ select.value = current || ''
282
+ } catch {
283
+ /* The opened run remains available when history cannot load. */
284
+ }
285
+ }
286
+ $('#run-select').addEventListener('change', () => {
287
+ if (state.drafts.size || state.pending.size || state.failures.size) return
288
+ const url = new URL(location.href)
289
+ if ($('#run-select').value) url.searchParams.set('run', $('#run-select').value)
290
+ else url.searchParams.delete('run')
291
+ url.searchParams.delete('tile')
292
+ history.replaceState(null, '', url)
293
+ ++state.comparisonGeneration
294
+ state.viewer = null
295
+ state.focus = null
296
+ clearFilters()
297
+ load()
298
+ })
299
+ function renderCoverage() {
300
+ const run = state.run
301
+ const coverage = run.coverage
302
+ const summary = $('#coverage-summary')
303
+ const details = $('#coverage-details')
304
+ details.replaceChildren()
305
+ if (!coverage) {
306
+ summary.textContent = 'Coverage unknown · legacy run'
307
+ details.append(el('p', null, 'Recapture to record planned, skipped and excluded states.'))
308
+ } else {
309
+ const count = (outcome) => coverage.filter((entry) => entry.outcome === outcome).length
310
+ const summaryText =
311
+ count('captured') +
312
+ ' of ' +
313
+ coverage.length +
314
+ ' planned tiles captured · ' +
315
+ count('failed') +
316
+ ' failed · ' +
317
+ count('skipped') +
318
+ ' unfinished · ' +
319
+ count('excluded') +
320
+ ' excluded' +
321
+ (count('filtered') ? ' · ' + count('filtered') + ' outside this capture’s route filter' : '')
322
+ summary.replaceChildren(
323
+ el('span', 'coverage-long', summaryText),
324
+ el(
325
+ 'span',
326
+ 'coverage-short',
327
+ 'Coverage · ' + count('captured') + ' of ' + coverage.length + ' captured',
328
+ ),
329
+ )
330
+ const list = el('ul')
331
+ for (const entry of coverage)
332
+ list.append(
333
+ el(
334
+ 'li',
335
+ null,
336
+ entry.route +
337
+ ' · ' +
338
+ entry.state +
339
+ ' · ' +
340
+ entry.viewport +
341
+ 'px · ' +
342
+ entry.theme +
343
+ ': ' +
344
+ entry.outcome +
345
+ (entry.reason ? ': ' + entry.reason : ''),
346
+ ),
347
+ )
348
+ details.append(list)
349
+ const matrix = el('div', 'coverage-scroll')
350
+ matrix.tabIndex = 0
351
+ matrix.setAttribute('role', 'region')
352
+ matrix.setAttribute('aria-label', 'Scrollable capture coverage')
353
+ const table = el('table', 'coverage-matrix')
354
+ table.id = 'coverage-matrix'
355
+ table.append(el('caption', null, 'Capture coverage'))
356
+ const columns = [
357
+ ...new Map(
358
+ coverage.map((entry) => [
359
+ entry.viewport + ':' + entry.theme,
360
+ { viewport: entry.viewport, theme: entry.theme },
361
+ ]),
362
+ ).values(),
363
+ ].sort(
364
+ (a, b) => a.viewport - b.viewport || (a.theme === b.theme ? 0 : a.theme === 'light' ? -1 : 1),
365
+ )
366
+ const head = el('thead')
367
+ const headers = el('tr')
368
+ const label = el('th', null, 'Route / state')
369
+ label.scope = 'col'
370
+ headers.append(label)
371
+ for (const column of columns) {
372
+ const header = el('th', null, column.viewport + 'px · ' + column.theme)
373
+ header.scope = 'col'
374
+ headers.append(header)
375
+ }
376
+ head.append(headers)
377
+ table.append(head)
378
+ const groups = new Map()
379
+ for (const entry of coverage) {
380
+ const key = JSON.stringify([entry.route, entry.state])
381
+ if (!groups.has(key)) groups.set(key, [])
382
+ groups.get(key).push(entry)
383
+ }
384
+ const body = el('tbody')
385
+ const tiles = new Map(run.tiles.map((tile) => [tile.id, tile]))
386
+ const labels = {
387
+ skipped: 'Unfinished',
388
+ excluded: 'Excluded',
389
+ filtered: 'Filtered out',
390
+ failed: 'Failed',
391
+ planned: 'Not captured',
392
+ }
393
+ for (const entries of groups.values()) {
394
+ const row = el('tr')
395
+ const label = el('th', null, entries[0].route + ' · ' + entries[0].state)
396
+ label.scope = 'row'
397
+ row.append(label)
398
+ for (const column of columns) {
399
+ const entry = entries.find(
400
+ (item) => item.viewport === column.viewport && item.theme === column.theme,
401
+ )
402
+ const cell = el('td')
403
+ const tile = entry && tiles.get(entry.id)
404
+ if (entry?.outcome === 'captured' && tile && !tile.error) {
405
+ const button = el('button', 'coverage-cell')
406
+ button.dataset.coverageTile = tile.id
407
+ button.addEventListener('click', () => {
408
+ clearFilters()
409
+ focusTile(tile.id, false)
410
+ openBox(tile, button)
411
+ })
412
+ cell.append(button)
413
+ } else {
414
+ cell.append(
415
+ el(
416
+ 'span',
417
+ 'coverage-unavailable',
418
+ !entry
419
+ ? 'Not planned'
420
+ : tile?.error
421
+ ? 'Failed'
422
+ : labels[entry.outcome] || 'Missing capture',
423
+ ),
424
+ )
425
+ cell.title = tile?.error || entry?.reason || ''
426
+ }
427
+ row.append(cell)
428
+ }
429
+ body.append(row)
430
+ }
431
+ table.append(body)
432
+ matrix.append(table)
433
+ details.prepend(matrix)
434
+ const inventory = el('details')
435
+ inventory.append(el('summary', null, 'Coverage reasons and capture environment'))
436
+ inventory.append(list)
437
+ details.append(inventory)
438
+ }
439
+ ;(details.querySelector('details') || details).append(
440
+ el(
441
+ 'pre',
442
+ null,
443
+ run.provenance ? JSON.stringify(run.provenance, null, 2) : 'Capture environment unknown.',
444
+ ),
445
+ )
446
+ }
447
+ function updateCoverageDecisions() {
448
+ if (!state.run) return
449
+ const tiles = new Map(state.run.tiles.map((tile) => [tile.id, tile]))
450
+ for (const button of document.querySelectorAll('[data-coverage-tile]')) {
451
+ const tile = tiles.get(button.dataset.coverageTile)
452
+ if (!tile) continue
453
+ const missing = state.comparison[tile.id]?.status === 'no-capture'
454
+ const value = missing ? null : decision(tile)
455
+ const label = missing
456
+ ? 'Missing capture'
457
+ : value === 'approve'
458
+ ? 'Approved'
459
+ : value === 'reject'
460
+ ? 'Rejected'
461
+ : 'Pending'
462
+ button.textContent =
463
+ (missing ? '' : value === 'approve' ? '✓ ' : value === 'reject' ? '× ' : '○ ') + label
464
+ button.dataset.decision = missing ? 'missing' : value || 'pending'
465
+ button.setAttribute(
466
+ 'aria-label',
467
+ tile.route +
468
+ ' · ' +
469
+ tile.state +
470
+ ' · ' +
471
+ tile.viewport +
472
+ 'px · ' +
473
+ tile.theme +
474
+ ' · ' +
475
+ label,
476
+ )
477
+ button.disabled = missing || state.pending.has(tile.id)
478
+ }
479
+ }
480
+ $('#report').addEventListener('click', () => {
481
+ if (!$('#report').disabled) location.href = apiPath('/api/report')
482
+ })
483
+ function renderStats() {
484
+ const activeFilters = [
485
+ state.status,
486
+ state.evidence,
487
+ state.viewport,
488
+ state.theme,
489
+ state.onlyFlagged,
490
+ state.onlyChanged,
491
+ state.onlyUnreviewed,
492
+ ].filter(Boolean).length
493
+ $('#toggle-filters').textContent = activeFilters
494
+ ? 'Filters (' + activeFilters + ' active)'
495
+ : 'Filters'
496
+ const tiles = state.run.tiles
497
+ const n = (fn) => tiles.filter(fn).length
498
+ const stats = $('#stats')
499
+ stats.replaceChildren()
500
+ for (const [label, value] of [
501
+ ['Captured', tiles.length],
502
+ ['Flagged', n(isFlagged)],
503
+ ['Approved', n((t) => decision(t) === 'approve')],
504
+ ['Rejected', n((t) => decision(t) === 'reject')],
505
+ ['Pending', n((t) => !decision(t))],
506
+ ]) {
507
+ const box = el('div', 'stat ' + label.toLowerCase())
508
+ box.append(el('b', null, String(value)), el('span', null, label))
509
+ stats.append(box)
510
+ }
511
+ for (const link of document.querySelectorAll('#route-nav a')) {
512
+ const routeTiles = tiles.filter((t) => t.route === link.dataset.route)
513
+ link.querySelector('span').textContent =
514
+ routeTiles.filter((t) => !!decision(t)).length + '/' + routeTiles.length
515
+ }
516
+ const reviewed = n((t) => !!decision(t))
517
+ $('#progress').max = tiles.length || 1
518
+ $('#progress').value = reviewed
519
+ $('#progress-label').textContent =
520
+ reviewed +
521
+ ' of ' +
522
+ tiles.length +
523
+ ' reviewed' +
524
+ (tiles.length && reviewed === tiles.length ? ' · Captured evidence reviewed' : '')
525
+ $('#percent').textContent = (tiles.length ? Math.round((reviewed / tiles.length) * 100) : 0) + '%'
526
+ const matching = visibleTiles().length
527
+ $('#results').textContent =
528
+ matching <= state.pageSize
529
+ ? matching + ' of ' + tiles.length + ' captures shown'
530
+ : matching + ' matching captures · ' + tiles.length + ' total'
531
+ updateControls()
532
+ }
533
+ function renderTile(t) {
534
+ const d = decision(t)
535
+ const card = el(
536
+ 'article',
537
+ 'tile' +
538
+ (d === 'approve' ? ' approved' : d === 'reject' ? ' rejected' : '') +
539
+ (state.focus === t.id ? ' focus' : ''),
540
+ )
541
+ const more = el('details', 'tile-more')
542
+ const moreParts = []
543
+ if (t.accessibility?.violations.length) {
544
+ const details = el('details', 'diagnostics')
545
+ details.append(el('summary', null, 'Automated accessibility findings'))
546
+ const list = el('ul')
547
+ for (const issue of t.accessibility.violations)
548
+ list.append(el('li', null, issue.id + ': ' + issue.help + ' · ' + issue.targets.join(', ')))
549
+ details.append(list)
550
+ more.append(details)
551
+ moreParts.push(t.accessibility.violations.length + ' a11y')
552
+ }
553
+ if (t.masks?.length) {
554
+ const details = el('details', 'diagnostics')
555
+ details.append(el('summary', null, 'Comparison masks (' + t.masks.length + ')'))
556
+ const list = el('ul')
557
+ for (const mask of t.masks)
558
+ list.append(
559
+ el(
560
+ 'li',
561
+ null,
562
+ mask.selector +
563
+ ': ' +
564
+ mask.reason +
565
+ ' · ' +
566
+ mask.boxes
567
+ .map(
568
+ (box) =>
569
+ Math.round(box.x) +
570
+ ',' +
571
+ Math.round(box.y) +
572
+ ' ' +
573
+ Math.round(box.w) +
574
+ '×' +
575
+ Math.round(box.h) +
576
+ 'px',
577
+ )
578
+ .join('; '),
579
+ ),
580
+ )
581
+ details.append(list)
582
+ more.append(details)
583
+ moreParts.push(t.masks.length + ' masks')
584
+ }
585
+ card.dataset.id = t.id
586
+ card.tabIndex = 0
587
+ card.setAttribute(
588
+ 'aria-label',
589
+ t.route +
590
+ ', ' +
591
+ t.state +
592
+ ', ' +
593
+ t.viewport +
594
+ ' pixels, ' +
595
+ t.theme +
596
+ ', ' +
597
+ (d === 'approve' ? 'approved' : d === 'reject' ? 'rejected' : 'pending'),
598
+ )
599
+ const head = el('div', 'head')
600
+ head.append(
601
+ el('span', null, t.viewport + 'px · ' + t.theme),
602
+ el('span', null, d === 'approve' ? 'Approved' : d === 'reject' ? 'Rejected' : 'Pending'),
603
+ )
604
+ const thumb = el('div', 'thumb')
605
+ thumb.tabIndex = 0
606
+ thumb.setAttribute('role', 'region')
607
+ thumb.setAttribute('aria-label', 'Screenshot preview for ' + t.id)
608
+ const inspect = el('button', 'button inspect', 'Inspect')
609
+ inspect.setAttribute('aria-label', 'Inspect screenshot')
610
+ inspect.disabled = !!t.error
611
+ inspect.addEventListener('click', () => openBox(t, inspect))
612
+ if (!t.error) {
613
+ const img = el('img')
614
+ img.loading = 'lazy'
615
+ img.src = apiPath('/img/' + encodeURIComponent(t.file))
616
+ img.alt = t.route + ' · ' + t.state + ' · ' + t.viewport + 'px · ' + t.theme
617
+ img.addEventListener('click', () => openBox(t, inspect))
618
+ img.addEventListener('error', () => {
619
+ thumb.replaceChildren(
620
+ el('p', 'capture-missing', 'Screenshot unavailable. Check this run’s capture files.'),
621
+ )
622
+ inspect.disabled = true
623
+ })
624
+ thumb.append(img)
625
+ } else
626
+ thumb.append(
627
+ el('p', 'capture-missing', 'No screenshot was captured. See the capture error below.'),
628
+ )
629
+ const badges = el('div', 'badges')
630
+ const errors = flags(t)
631
+ if (!errors.length && !t.textClipped.length && !t.warnings?.length)
632
+ badges.append(el('span', 'badge ok', 'Checks clear'))
633
+ for (const text of errors) badges.append(el('span', 'badge', text))
634
+ if (t.textClipped.length)
635
+ badges.append(
636
+ el(
637
+ 'span',
638
+ 'badge warn',
639
+ t.textClipped.length + ' clipped text warning' + (t.textClipped.length > 1 ? 's' : ''),
640
+ ),
641
+ )
642
+ if (t.warnings?.length)
643
+ badges.append(
644
+ el(
645
+ 'span',
646
+ 'badge warn',
647
+ t.warnings.length + ' capture warning' + (t.warnings.length > 1 ? 's' : ''),
648
+ ),
649
+ )
650
+ card.append(head, thumb, badges)
651
+ const diagnostics = [
652
+ ...t.consoleErrors,
653
+ ...t.pageErrors,
654
+ ...t.brokenImages,
655
+ ...(t.warnings || []),
656
+ ...t.textClipped.map((x) => x.selector + ': ' + x.text),
657
+ ]
658
+ if (diagnostics.length) {
659
+ const details = el('details', 'diagnostics')
660
+ details.append(el('summary', null, 'Diagnostic details (' + diagnostics.length + ')'))
661
+ const list = el('ul')
662
+ for (const item of diagnostics) list.append(el('li', null, item))
663
+ details.append(list)
664
+ more.append(details)
665
+ moreParts.push(diagnostics.length + ' diagnostic' + (diagnostics.length > 1 ? 's' : ''))
666
+ }
667
+ const c = state.critique[t.id]
668
+ if (c) {
669
+ const box = el('div', 'crit')
670
+ box.append(
671
+ el(
672
+ 'b',
673
+ null,
674
+ c.verdict === 'pass'
675
+ ? 'Critique: pass'
676
+ : c.verdict === 'fix'
677
+ ? 'Critique: needs work'
678
+ : 'Critique unavailable',
679
+ ),
680
+ )
681
+ if (c.error) box.append(el('p', null, c.error))
682
+ if (c.issues?.length) {
683
+ const list = el('ul')
684
+ for (const issue of c.issues) {
685
+ const item = el('li')
686
+ item.append(
687
+ el('span', 'sev', issue.severity + ' '),
688
+ document.createTextNode(issue.what + (issue.where ? ' (' + issue.where + ')' : '')),
689
+ )
690
+ list.append(item)
691
+ }
692
+ box.append(list)
693
+ }
694
+ more.append(box)
695
+ badges.append(
696
+ el(
697
+ 'span',
698
+ 'badge' + (c.verdict === 'pass' ? ' ok' : c.verdict === 'fix' ? '' : ' warn'),
699
+ c.verdict === 'pass'
700
+ ? 'Critique: pass'
701
+ : c.verdict === 'fix'
702
+ ? 'Critique: needs work'
703
+ : 'Critique unavailable',
704
+ ),
705
+ )
706
+ moreParts.push('critique')
707
+ }
708
+ const comparison = state.comparison[t.id]
709
+ card.append(
710
+ el(
711
+ 'p',
712
+ 'comparison-status',
713
+ comparisonLabels[comparison?.status] ||
714
+ (state.comparisonLoading ? 'Comparing…' : 'Not compared'),
715
+ ),
716
+ )
717
+ const actions = el('div', 'actions')
718
+ actions.append(inspect)
719
+ for (const [value, label] of [
720
+ ['approve', 'Approve'],
721
+ ['reject', 'Reject'],
722
+ ]) {
723
+ const button = el('button', value, label)
724
+ button.setAttribute('aria-pressed', String(d === value))
725
+ button.disabled = state.pending.has(t.id)
726
+ button.setAttribute('aria-label', label + ' ' + t.id)
727
+ button.addEventListener('click', () => setDecision(t, value))
728
+ actions.append(button)
729
+ }
730
+ const noteLabel = el('label', 'note-label', 'Review note')
731
+ const note = el('textarea')
732
+ note.placeholder = 'What should change?'
733
+ note.maxLength = 2000
734
+ note.value = state.drafts.get(t.id) ?? state.review[t.id]?.note ?? ''
735
+ note.setAttribute('aria-label', 'Review note for ' + t.id)
736
+ const noteHelp = el('small', null, 'Up to 2,000 characters. Saves when you leave this field.')
737
+ noteHelp.id = 'note-help-' + state.run.tiles.indexOf(t)
738
+ note.setAttribute('aria-describedby', noteHelp.id)
739
+ note.addEventListener('input', () => {
740
+ if (note.value === (state.review[t.id]?.note ?? '') && !state.pending.has(t.id))
741
+ state.drafts.delete(t.id)
742
+ else state.drafts.set(t.id, note.value)
743
+ if (state.failures.has(t.id) && 'note' in state.failures.get(t.id))
744
+ state.failures.get(t.id).note = note.value
745
+ updateControls()
746
+ })
747
+ note.addEventListener('change', () => {
748
+ if (state.drafts.has(t.id)) save(t, { note: note.value })
749
+ })
750
+ noteLabel.append(note, noteHelp)
751
+ more.prepend(noteLabel)
752
+ const hasNote = !!(state.drafts.get(t.id) ?? state.review[t.id]?.note)
753
+ if (hasNote) moreParts.unshift('note')
754
+ more.prepend(
755
+ el('summary', null, 'Note & details' + (moreParts.length ? ' · ' + moreParts.join(' · ') : '')),
756
+ )
757
+ more.open = hasNote || state.failures.has(t.id)
758
+ card.append(actions, more)
759
+ card.addEventListener('focusin', () => {
760
+ state.focus = t.id
761
+ document.querySelectorAll('.tile.focus').forEach((x) => {
762
+ x.classList.remove('focus')
763
+ })
764
+ card.classList.add('focus')
765
+ })
766
+ return card
767
+ }
768
+ function render() {
769
+ if (!state.run) return
770
+ renderStats()
771
+ const main = $('#cards')
772
+ main.replaceChildren()
773
+ const visible = visibleTiles()
774
+ state.page = Math.min(state.page, Math.max(0, Math.ceil(visible.length / state.pageSize) - 1))
775
+ for (const pager of document.querySelectorAll('.pagination'))
776
+ pager.hidden = visible.length <= state.pageSize
777
+ $('#page-label').textContent =
778
+ 'Page ' +
779
+ (state.page + 1) +
780
+ ' of ' +
781
+ Math.max(1, Math.ceil(visible.length / state.pageSize)) +
782
+ ' · ' +
783
+ Math.min(state.pageSize, visible.length - state.page * state.pageSize) +
784
+ ' captures on this page'
785
+ $('#pagination-bottom .page-label').textContent = $('#page-label').textContent
786
+ for (const button of document.querySelectorAll('[data-page-step]'))
787
+ button.disabled =
788
+ Number(button.dataset.pageStep) < 0
789
+ ? state.page === 0
790
+ : (state.page + 1) * state.pageSize >= visible.length
791
+ if (!state.run.tiles.length) {
792
+ showEmpty(
793
+ 'No captures in this run.',
794
+ 'Capture your site with the Design Spine CLI, then open the new run for review.',
795
+ )
796
+ return
797
+ }
798
+ if (!visible.length) {
799
+ showEmpty(
800
+ 'No captures match.',
801
+ 'Try another route, status, width, or theme. Your review decisions are still saved.',
802
+ 'Clear filters',
803
+ clearFilters,
804
+ )
805
+ return
806
+ }
807
+ const groups = new Map()
808
+ for (const t of visible.slice(state.page * state.pageSize, (state.page + 1) * state.pageSize)) {
809
+ if (!groups.has(t.route)) groups.set(t.route, new Map())
810
+ const states = groups.get(t.route)
811
+ if (!states.has(t.state)) states.set(t.state, [])
812
+ states.get(t.state).push(t)
813
+ }
814
+ for (const [route, states] of groups) {
815
+ const section = el('section', 'route')
816
+ section.id = 'route-' + [...new Set(state.run.tiles.map((t) => t.route))].indexOf(route)
817
+ const heading = el('h2')
818
+ heading.tabIndex = -1
819
+ heading.append(el('code', null, route))
820
+ section.append(heading)
821
+ for (const [name, tiles] of states) {
822
+ const group = el('div', 'state')
823
+ group.append(el('h3', null, name))
824
+ const row = el('div', 'row')
825
+ for (const t of tiles) row.append(renderTile(t))
826
+ group.append(row)
827
+ section.append(group)
828
+ }
829
+ main.append(section)
830
+ }
831
+ }
832
+ let saveStatusTimer = 0
833
+ function setSaveStatus(message, error = false) {
834
+ const box = $('#save-status')
835
+ box.classList.toggle('error', error)
836
+ box.replaceChildren(el('span', null, message))
837
+ clearTimeout(saveStatusTimer)
838
+ if (!error && !state.failures.size && !state.pending.size)
839
+ saveStatusTimer = setTimeout(() => {
840
+ if (!state.failures.size && !state.pending.size) box.replaceChildren()
841
+ }, 3000)
842
+ updateViewerControls()
843
+ if (state.failures.size) {
844
+ const retry = el('button', 'button', 'Retry unsaved changes')
845
+ retry.addEventListener('click', retryFailures)
846
+ box.append(retry)
847
+ }
848
+ }
849
+ async function save(t, patch) {
850
+ const previous = state.pending.get(t.id) || Promise.resolve()
851
+ const task = previous
852
+ .catch(() => {})
853
+ .then(async () => {
854
+ setSaveStatus('Saving changes…')
855
+ try {
856
+ const response = await fetch(apiPath('/api/review'), {
857
+ method: 'POST',
858
+ headers: { 'content-type': 'application/json' },
859
+ body: JSON.stringify({ tileId: t.id, imageHash: t.imageHash, ...patch }),
860
+ })
861
+ if (!response.ok) throw new Error('HTTP ' + response.status)
862
+ const entry = await response.json()
863
+ if (
864
+ !entry ||
865
+ !['approve', 'reject', null].includes(entry.decision) ||
866
+ typeof entry.note !== 'string'
867
+ )
868
+ throw new Error('Invalid save response')
869
+ state.review[t.id] = entry
870
+ if (state.drafts.get(t.id) === entry.note) state.drafts.delete(t.id)
871
+ const failed = state.failures.get(t.id)
872
+ if (failed) {
873
+ for (const key of Object.keys(patch)) delete failed[key]
874
+ if (!Object.keys(failed).length) state.failures.delete(t.id)
875
+ }
876
+ return true
877
+ } catch {
878
+ state.failures.set(t.id, { ...state.failures.get(t.id), ...patch })
879
+ return false
880
+ }
881
+ })
882
+ state.pending.set(t.id, task)
883
+ updateControls()
884
+ document.querySelectorAll('.tile').forEach((card) => {
885
+ if (card.dataset.id === t.id)
886
+ card.querySelectorAll('.actions button').forEach((button) => {
887
+ button.disabled = true
888
+ })
889
+ })
890
+ const saved = await task
891
+ if (state.pending.get(t.id) === task) state.pending.delete(t.id)
892
+ updateControls()
893
+ document.querySelectorAll('.tile').forEach((card) => {
894
+ if (card.dataset.id === t.id)
895
+ card.querySelectorAll('.actions button').forEach((button) => {
896
+ button.disabled =
897
+ state.pending.has(t.id) || (button.classList.contains('inspect') && !!t.error)
898
+ })
899
+ })
900
+ if (state.failures.size)
901
+ setSaveStatus(
902
+ 'Changes could not be saved. Your drafts remain here; retry before leaving.',
903
+ true,
904
+ )
905
+ else if (state.pending.size) setSaveStatus('Saving changes…')
906
+ else
907
+ setSaveStatus(
908
+ state.drafts.size
909
+ ? 'Saved. Other note drafts still need saving.'
910
+ : 'All changes saved locally.',
911
+ )
912
+ return saved
913
+ }
914
+ async function setDecision(t, value) {
915
+ if (state.pending.has(t.id)) return
916
+ const before = visibleTiles()
917
+ const index = before.findIndex((x) => x.id === t.id)
918
+ const viewerBefore = viewerTiles()
919
+ const patch = { decision: decision(t) === value ? null : value }
920
+ if (state.drafts.has(t.id)) patch.note = state.drafts.get(t.id)
921
+ await save(t, patch)
922
+ const keep = state.focus
923
+ render()
924
+ const remaining = visibleTiles()
925
+ if ($('#lightbox').open) reconcileViewer(viewerBefore)
926
+ else
927
+ focusTile(
928
+ remaining.some((x) => x.id === keep)
929
+ ? keep
930
+ : remaining[Math.min(Math.max(0, index), remaining.length - 1)]?.id,
931
+ false,
932
+ )
933
+ }
934
+ async function retryFailures() {
935
+ const viewerBefore = viewerTiles()
936
+ for (const [id, failed] of [...state.failures]) {
937
+ const tile = state.run.tiles.find((t) => t.id === id)
938
+ if (!tile || state.pending.has(id)) continue
939
+ const patch = { ...failed }
940
+ if (state.drafts.has(id)) patch.note = state.drafts.get(id)
941
+ await save(tile, patch)
942
+ }
943
+ const keep = state.focus
944
+ render()
945
+ if ($('#lightbox').open) reconcileViewer(viewerBefore)
946
+ else focusTile(keep, false)
947
+ }
948
+ function focusTile(id, scroll = true) {
949
+ state.focus = id || null
950
+ const index = visibleTiles().findIndex((t) => t.id === id)
951
+ if (index >= 0 && Math.floor(index / state.pageSize) !== state.page) {
952
+ state.page = Math.floor(index / state.pageSize)
953
+ render()
954
+ }
955
+ const node = id && document.querySelector('.tile[data-id="' + CSS.escape(id) + '"]')
956
+ if (node) {
957
+ node.focus({ preventScroll: true })
958
+ if (scroll)
959
+ node.scrollIntoView({
960
+ block: 'center',
961
+ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
962
+ })
963
+ } else if (!visibleTiles().length) focusFilterReset()
964
+ }
965
+ function focusFilterReset() {
966
+ const clear = $('#clear-filters')
967
+ const target = clear.getClientRects().length ? clear : $('#toggle-filters')
968
+ target.focus()
969
+ }
970
+ function move(direction) {
971
+ const visible = visibleTiles()
972
+ if (!visible.length) return
973
+ const index = visible.findIndex((t) => t.id === state.focus)
974
+ const next =
975
+ index < 0
976
+ ? direction > 0
977
+ ? 0
978
+ : visible.length - 1
979
+ : Math.min(visible.length - 1, Math.max(0, index + direction))
980
+ focusTile(visible[next].id)
981
+ }
982
+ function updateRegions() {
983
+ const tile = state.run?.tiles.find((t) => t.id === state.viewer)
984
+ const img = $('#viewer-image')
985
+ const note = state.review[tile?.id]?.anchor
986
+ const pin = $('#note-overlay')
987
+ pin.replaceChildren()
988
+ pin.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight)
989
+ pin.toggleAttribute(
990
+ 'hidden',
991
+ !note ||
992
+ note.imageHash !== tile?.imageHash ||
993
+ !['current', 'split', 'overlay'].includes($('#viewer-mode').value) ||
994
+ !img.naturalWidth,
995
+ )
996
+ if (!pin.hasAttribute('hidden')) {
997
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
998
+ circle.setAttribute('cx', note.x)
999
+ circle.setAttribute('cy', note.y)
1000
+ circle.setAttribute('r', '10')
1001
+ pin.append(circle)
1002
+ }
1003
+ const supported = ['current', 'split'].includes($('#viewer-mode').value)
1004
+ const boxes = supported
1005
+ ? (tile?.blankRegions?.boxes || []).filter(
1006
+ (b) => [b.x, b.y, b.w, b.h].every(Number.isFinite) && b.w > 0 && b.h > 0,
1007
+ )
1008
+ : []
1009
+ $('.diagnostic-controls').hidden = !boxes.length
1010
+ const toggle = $('#viewer-regions')
1011
+ toggle.disabled = !boxes.length
1012
+ const select = $('#viewer-region')
1013
+ const selected = select.value
1014
+ select.replaceChildren(new Option('Choose a region', ''))
1015
+ boxes.forEach((_box, index) => {
1016
+ select.add(new Option('Possible blank region ' + (index + 1), String(index)))
1017
+ })
1018
+ select.disabled = !boxes.length
1019
+ select.value = selected
1020
+ const overlay = $('#diagnostic-overlay')
1021
+ overlay.replaceChildren()
1022
+ overlay.toggleAttribute(
1023
+ 'hidden',
1024
+ !boxes.length ||
1025
+ toggle.getAttribute('aria-pressed') !== 'true' ||
1026
+ img.hidden ||
1027
+ !img.complete ||
1028
+ !img.naturalWidth,
1029
+ )
1030
+ overlay.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight)
1031
+ boxes.forEach((box, index) => {
1032
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
1033
+ for (const [name, value] of Object.entries({ x: box.x, y: box.y, width: box.w, height: box.h }))
1034
+ rect.setAttribute(name, value)
1035
+ rect.dataset.region = index
1036
+ overlay.append(rect)
1037
+ })
1038
+ }
1039
+ function viewerTiles() {
1040
+ return visibleTiles().filter((t) => !t.error)
1041
+ }
1042
+ function reconcileViewer(before) {
1043
+ const tiles = viewerTiles()
1044
+ const remaining = new Set(tiles.map((t) => t.id))
1045
+ if (!remaining.has(state.viewer)) {
1046
+ const index = before.findIndex((t) => t.id === state.viewer)
1047
+ const neighbors = [...before.slice(index + 1), ...before.slice(0, Math.max(0, index)).reverse()]
1048
+ state.viewer = neighbors.find((t) => remaining.has(t.id))?.id || tiles[0]?.id || null
1049
+ if (state.viewer) {
1050
+ updateViewer()
1051
+ $('#viewer-close').focus()
1052
+ } else $('#lightbox').close()
1053
+ } else updateViewerControls()
1054
+ }
1055
+ function updateViewerControls() {
1056
+ const tile = state.run?.tiles.find((t) => t.id === state.viewer)
1057
+ const pending = !!tile && state.pending.has(tile.id)
1058
+ for (const [id, value] of [
1059
+ ['#viewer-approve', 'approve'],
1060
+ ['#viewer-reject', 'reject'],
1061
+ ]) {
1062
+ $(id).disabled = !tile || pending
1063
+ $(id).setAttribute('aria-pressed', String(!!tile && decision(tile) === value))
1064
+ }
1065
+ $('#viewer-decision').textContent = tile
1066
+ ? decision(tile) === 'approve'
1067
+ ? 'Approved'
1068
+ : decision(tile) === 'reject'
1069
+ ? 'Rejected'
1070
+ : 'Pending'
1071
+ : 'Pending'
1072
+ $('#viewer-save-note').disabled = !tile || pending || !state.drafts.has(tile.id)
1073
+ $('#viewer-pending').disabled = !viewerTiles().some((t) => t.id !== state.viewer && !decision(t))
1074
+ $('#viewer-retry').hidden = !state.failures.size
1075
+ $('#viewer-retry').disabled = !!state.pending.size
1076
+ $('#viewer-note-summary').textContent =
1077
+ 'Review note' +
1078
+ (tile && state.drafts.has(tile.id)
1079
+ ? ' · unsaved'
1080
+ : tile && state.review[tile.id]?.note
1081
+ ? ' · saved'
1082
+ : '')
1083
+ const status = $('#viewer-save-status')
1084
+ status.classList.toggle('error', !!state.failures.size)
1085
+ status.textContent = state.failures.size
1086
+ ? 'Changes could not be saved. Your drafts remain here; retry before leaving.'
1087
+ : state.pending.size
1088
+ ? 'Saving changes…'
1089
+ : state.drafts.size
1090
+ ? 'Unsaved note drafts remain. Save each note or include it with a decision.'
1091
+ : 'All changes saved locally. Baselines have not changed.'
1092
+ }
1093
+ function updateViewer() {
1094
+ const tiles = viewerTiles()
1095
+ const index = tiles.findIndex((t) => t.id === state.viewer)
1096
+ const tile = tiles[index]
1097
+ if (!tile) return
1098
+ state.focus = tile.id
1099
+ const url = new URL(location.href)
1100
+ url.searchParams.set('tile', tile.id)
1101
+ history.replaceState(null, '', url)
1102
+ $('#viewer-note').value = state.drafts.get(tile.id) ?? state.review[tile.id]?.note ?? ''
1103
+ updateViewerControls()
1104
+ $('#viewer-title').textContent = tile.route + ' · ' + tile.state
1105
+ $('#viewer-position').textContent =
1106
+ tile.viewport + 'px · ' + tile.theme + ' · ' + (index + 1) + ' of ' + tiles.length
1107
+ const comparison = state.comparison[tile.id] || {}
1108
+ const mode = $('#viewer-mode')
1109
+ for (const option of mode.options)
1110
+ option.disabled =
1111
+ option.value === 'diff'
1112
+ ? !comparison.diffUrl
1113
+ : ['baseline', 'split', 'overlay'].includes(option.value) && !comparison.baselineUrl
1114
+ if (mode.selectedOptions[0].disabled) mode.value = 'current'
1115
+ if (comparison.status === 'size-changed') {
1116
+ mode.querySelector('[value=overlay]').disabled = true
1117
+ if (mode.value === 'overlay') mode.value = 'current'
1118
+ }
1119
+ const view = mode.value
1120
+ $('#overlay-image').hidden = view !== 'overlay'
1121
+ $('#overlay-image').src = view === 'overlay' ? comparison.baselineUrl : ''
1122
+ $('#blend-control').hidden = view !== 'overlay'
1123
+ const changes = $('#change-region')
1124
+ changes.replaceChildren(new Option('Choose a change', ''))
1125
+ ;(comparison.regions || []).forEach((box, index) => {
1126
+ changes.add(new Option('Change ' + (index + 1) + ' · y ' + box.y + 'px', String(index)))
1127
+ })
1128
+ changes.disabled = !comparison.regions?.length || view === 'baseline'
1129
+ $('#anchor-note').disabled = !tile.imageHash || view !== 'current' || state.pending.has(tile.id)
1130
+ $('#anchor-note').setAttribute('aria-pressed', 'false')
1131
+ const anchor = state.review[tile.id]?.anchor
1132
+ $('#clear-anchor').hidden = !anchor
1133
+ $('#jump-anchor').hidden =
1134
+ !anchor ||
1135
+ anchor.imageHash !== tile.imageHash ||
1136
+ !['current', 'split', 'overlay'].includes(view)
1137
+ $('#anchor-x').value = anchor?.x ?? 0
1138
+ $('#anchor-y').value = anchor?.y ?? 0
1139
+ $('#save-anchor').disabled = !tile.imageHash || view !== 'current' || state.pending.has(tile.id)
1140
+ $('#anchor-status').textContent = anchor
1141
+ ? anchor.imageHash === tile.imageHash
1142
+ ? 'Note location: ' + anchor.x + ', ' + anchor.y + 'px.'
1143
+ : 'Note location belongs to different evidence.'
1144
+ : ''
1145
+ $('#viewer-size').disabled = view === 'split'
1146
+ if (view === 'split') {
1147
+ $('#viewer-image').classList.remove('actual')
1148
+ $('#baseline-image').classList.remove('actual')
1149
+ $('#viewer-size').setAttribute('aria-pressed', 'false')
1150
+ $('#viewer-size').textContent = 'Actual size'
1151
+ }
1152
+ $('#baseline-pane').hidden = view !== 'split'
1153
+ $('.viewer-pair').classList.toggle('split', view === 'split')
1154
+ $('#baseline-image').src = view === 'split' ? comparison.baselineUrl : ''
1155
+ $('#baseline-image').alt = 'Baseline for ' + tile.route + ', ' + tile.state
1156
+ $('#current-caption').textContent =
1157
+ view === 'baseline'
1158
+ ? 'Baseline'
1159
+ : view === 'diff'
1160
+ ? 'Difference · highlighted pixels'
1161
+ : 'Current'
1162
+ $('#comparison-context').textContent =
1163
+ (comparisonLabels[comparison.status] || 'Not compared') +
1164
+ ' · threshold ' +
1165
+ (state.threshold * 100).toFixed(2) +
1166
+ '%' +
1167
+ (comparison.ratio !== undefined
1168
+ ? ' · ' + (comparison.ratio * 100).toFixed(3) + '% pixels differ'
1169
+ : '') +
1170
+ (comparison.baselineRun ? ' · baseline ' + comparison.baselineRun : '') +
1171
+ (comparison.approvedAt ? ' · approved ' + comparison.approvedAt : '') +
1172
+ (comparison.ignored?.length
1173
+ ? ' · ' + comparison.ignored.length + ' comparison masks; original evidence retained'
1174
+ : '') +
1175
+ (comparison.recipeCompatible === false ? ' · capture recipe differs or unknown' : '') +
1176
+ (comparison.provenance ? ' · environment ' + comparison.provenance : '') +
1177
+ (comparison.status === 'size-changed'
1178
+ ? ' · Native dimensions differ; pixel overlay unavailable.'
1179
+ : '')
1180
+ const img = $('#viewer-image')
1181
+ img.hidden = false
1182
+ $('#viewer-error').hidden = true
1183
+ img.alt =
1184
+ $('#current-caption').textContent +
1185
+ ' for ' +
1186
+ tile.route +
1187
+ ', ' +
1188
+ tile.state +
1189
+ ', ' +
1190
+ tile.viewport +
1191
+ 'px, ' +
1192
+ tile.theme
1193
+ img.src =
1194
+ view === 'baseline'
1195
+ ? comparison.baselineUrl
1196
+ : view === 'diff'
1197
+ ? comparison.diffUrl
1198
+ : apiPath('/img/' + encodeURIComponent(tile.file))
1199
+ $('#viewer-download').href = img.src
1200
+ $('#viewer-download').download = tile.file
1201
+ $('#viewer-download').setAttribute('aria-disabled', 'false')
1202
+ $('#viewer-download').removeAttribute('tabindex')
1203
+ $('#viewer-prev').disabled = index <= 0
1204
+ $('#viewer-next').disabled = index >= tiles.length - 1
1205
+ $('.evidence-frame').classList.toggle('actual', img.classList.contains('actual'))
1206
+ updateRegions()
1207
+ $('.viewer-canvas').scrollTop = 0
1208
+ $('.viewer-canvas').scrollLeft = 0
1209
+ }
1210
+ function openBox(tile, source = document.activeElement) {
1211
+ if (tile.error) return
1212
+ $('#viewer-mode').value = 'current'
1213
+ state.viewer = tile.id
1214
+ state.returnFocus = source
1215
+ $('#viewer-image').classList.remove('actual')
1216
+ $('#baseline-image').classList.remove('actual')
1217
+ $('#viewer-size').setAttribute('aria-pressed', 'false')
1218
+ $('#viewer-size').textContent = 'Actual size'
1219
+ const url = new URL(location.href)
1220
+ url.searchParams.set('tile', tile.id)
1221
+ history.replaceState(null, '', url)
1222
+ updateViewer()
1223
+ $('#lightbox').showModal()
1224
+ $('#viewer-close').focus()
1225
+ }
1226
+ function changeViewer(direction) {
1227
+ const tiles = viewerTiles()
1228
+ const index = tiles.findIndex((t) => t.id === state.viewer)
1229
+ const tile = tiles[index + direction]
1230
+ if (tile) {
1231
+ state.viewer = tile.id
1232
+ updateViewer()
1233
+ }
1234
+ }
1235
+ $('#save-anchor').addEventListener('click', async () => {
1236
+ const tile = state.run.tiles.find((t) => t.id === state.viewer)
1237
+ const x = Number($('#anchor-x').value),
1238
+ y = Number($('#anchor-y').value)
1239
+ if (
1240
+ !Number.isInteger(x) ||
1241
+ !Number.isInteger(y) ||
1242
+ x < 0 ||
1243
+ y < 0 ||
1244
+ x >= $('#viewer-image').naturalWidth ||
1245
+ y >= $('#viewer-image').naturalHeight
1246
+ ) {
1247
+ $('#anchor-status').textContent = 'Enter coordinates within the original screenshot dimensions.'
1248
+ return
1249
+ }
1250
+ await save(tile, { anchor: { x, y, imageHash: tile.imageHash } })
1251
+ updateViewer()
1252
+ })
1253
+ $('#jump-anchor').addEventListener('click', () => {
1254
+ const anchor = state.review[state.viewer]?.anchor
1255
+ if (!anchor) return
1256
+ const image = $('#viewer-image')
1257
+ const canvas = $('.viewer-canvas')
1258
+ const bounds = image.getBoundingClientRect()
1259
+ const container = canvas.getBoundingClientRect()
1260
+ canvas.scrollTop +=
1261
+ bounds.top - container.top + (anchor.y * bounds.height) / image.naturalHeight - 24
1262
+ })
1263
+ $('#blend').addEventListener('input', (event) => {
1264
+ $('#overlay-image').style.opacity = event.target.value / 100
1265
+ })
1266
+ $('#change-region').addEventListener('change', (event) => {
1267
+ const box = state.comparison[state.viewer]?.regions?.[Number(event.target.value)]
1268
+ const img = $('#viewer-image')
1269
+ if (event.target.value === '' || !box || !img.naturalHeight) return
1270
+ const canvas = $('.viewer-canvas')
1271
+ const bounds = img.getBoundingClientRect()
1272
+ const container = canvas.getBoundingClientRect()
1273
+ canvas.scrollTop += bounds.top - container.top + (box.y * bounds.height) / img.naturalHeight - 24
1274
+ })
1275
+ $('#anchor-note').addEventListener('click', () => {
1276
+ const button = $('#anchor-note')
1277
+ const active = button.getAttribute('aria-pressed') !== 'true'
1278
+ button.setAttribute('aria-pressed', String(active))
1279
+ $('#anchor-status').textContent = active
1280
+ ? 'Click the screenshot to save the location for this note.'
1281
+ : ''
1282
+ })
1283
+ $('#viewer-image').addEventListener('click', async (event) => {
1284
+ if ($('#anchor-note').getAttribute('aria-pressed') !== 'true') return
1285
+ const tile = state.run.tiles.find((t) => t.id === state.viewer)
1286
+ const image = event.currentTarget
1287
+ const bounds = image.getBoundingClientRect()
1288
+ const anchor = {
1289
+ x: Math.min(
1290
+ image.naturalWidth - 1,
1291
+ Math.max(0, Math.floor(((event.clientX - bounds.left) * image.naturalWidth) / bounds.width)),
1292
+ ),
1293
+ y: Math.min(
1294
+ image.naturalHeight - 1,
1295
+ Math.max(0, Math.floor(((event.clientY - bounds.top) * image.naturalHeight) / bounds.height)),
1296
+ ),
1297
+ imageHash: tile.imageHash,
1298
+ }
1299
+ await save(tile, { anchor })
1300
+ updateViewer()
1301
+ })
1302
+ $('#clear-anchor').addEventListener('click', async () => {
1303
+ const tile = state.run.tiles.find((t) => t.id === state.viewer)
1304
+ await save(tile, { anchor: null })
1305
+ updateViewer()
1306
+ })
1307
+ $('#viewer-note').addEventListener('input', (event) => {
1308
+ const tile = state.run?.tiles.find((t) => t.id === state.viewer)
1309
+ if (!tile) return
1310
+ const value = event.target.value
1311
+ if (value === (state.review[tile.id]?.note ?? '') && !state.pending.has(tile.id))
1312
+ state.drafts.delete(tile.id)
1313
+ else state.drafts.set(tile.id, value)
1314
+ if (state.failures.has(tile.id) && 'note' in state.failures.get(tile.id))
1315
+ state.failures.get(tile.id).note = value
1316
+ updateControls()
1317
+ })
1318
+ $('#viewer-save-note').addEventListener('click', async () => {
1319
+ const tile = state.run?.tiles.find((t) => t.id === state.viewer)
1320
+ if (tile && state.drafts.has(tile.id) && !state.pending.has(tile.id)) {
1321
+ await save(tile, { note: state.drafts.get(tile.id) })
1322
+ render()
1323
+ }
1324
+ })
1325
+ for (const [id, value] of [
1326
+ ['#viewer-approve', 'approve'],
1327
+ ['#viewer-reject', 'reject'],
1328
+ ])
1329
+ $(id).addEventListener('click', () => {
1330
+ const tile = state.run?.tiles.find((t) => t.id === state.viewer)
1331
+ if (tile) setDecision(tile, value)
1332
+ })
1333
+ $('#viewer-pending').addEventListener('click', () => {
1334
+ const tiles = viewerTiles()
1335
+ const index = tiles.findIndex((t) => t.id === state.viewer)
1336
+ const next = [...tiles.slice(index + 1), ...tiles.slice(0, index)].find((t) => !decision(t))
1337
+ if (next) {
1338
+ state.viewer = next.id
1339
+ updateViewer()
1340
+ }
1341
+ })
1342
+ $('#viewer-retry').addEventListener('click', retryFailures)
1343
+ $('#viewer-close').addEventListener('click', () => $('#lightbox').close())
1344
+ $('#lightbox').addEventListener('close', () => {
1345
+ const url = new URL(location.href)
1346
+ url.searchParams.delete('tile')
1347
+ history.replaceState(null, '', url)
1348
+ const viewed = state.viewer
1349
+ const inspect =
1350
+ state.returnFocus?.classList.contains('inspect') &&
1351
+ state.returnFocus.closest('.tile')?.dataset.id === viewed
1352
+ state.viewer = null
1353
+ render()
1354
+ if (state.returnFocus?.dataset.coverageTile && state.returnFocus.isConnected) {
1355
+ state.returnFocus.focus({ preventScroll: true })
1356
+ return
1357
+ }
1358
+ if (visibleTiles().some((t) => t.id === viewed)) {
1359
+ focusTile(viewed, false)
1360
+ if (inspect)
1361
+ document
1362
+ .querySelector('.tile[data-id="' + CSS.escape(viewed) + '"] .inspect')
1363
+ ?.focus({ preventScroll: true })
1364
+ } else focusFilterReset()
1365
+ })
1366
+ $('#lightbox').addEventListener('click', (event) => {
1367
+ if (event.target === $('#lightbox')) {
1368
+ const rect = event.target.getBoundingClientRect()
1369
+ if (
1370
+ event.clientX < rect.left ||
1371
+ event.clientX > rect.right ||
1372
+ event.clientY < rect.top ||
1373
+ event.clientY > rect.bottom
1374
+ )
1375
+ event.target.close()
1376
+ }
1377
+ })
1378
+ $('#viewer-mode').addEventListener('change', () => {
1379
+ $('#viewer-region').value = ''
1380
+ updateViewer()
1381
+ })
1382
+ $('#viewer-image').addEventListener('load', updateRegions)
1383
+ $('#viewer-regions').addEventListener('click', () => {
1384
+ const button = $('#viewer-regions')
1385
+ const enabled = button.getAttribute('aria-pressed') !== 'true'
1386
+ button.setAttribute('aria-pressed', String(enabled))
1387
+ button.textContent = enabled ? 'Hide blank regions' : 'Show blank regions'
1388
+ updateRegions()
1389
+ })
1390
+ $('#viewer-region').addEventListener('change', () => {
1391
+ const value = $('#viewer-region').value
1392
+ if (value === '') return
1393
+ $('#viewer-regions').setAttribute('aria-pressed', 'true')
1394
+ $('#viewer-regions').textContent = 'Hide blank regions'
1395
+ updateRegions()
1396
+ $('#diagnostic-overlay')
1397
+ .querySelector('[data-region="' + value + '"]')
1398
+ ?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' })
1399
+ })
1400
+ $('#viewer-refresh').addEventListener('click', async () => {
1401
+ const button = $('#viewer-refresh')
1402
+ button.disabled = true
1403
+ try {
1404
+ await loadComparison()
1405
+ if (state.viewer) updateViewer()
1406
+ } finally {
1407
+ button.disabled = false
1408
+ }
1409
+ })
1410
+ $('#baseline-image').addEventListener('error', () => {
1411
+ if (!$('#baseline-pane').hidden) $('#viewer-error').hidden = false
1412
+ })
1413
+ $('#viewer-prev').addEventListener('click', () => changeViewer(-1))
1414
+ $('#viewer-next').addEventListener('click', () => changeViewer(1))
1415
+ $('#viewer-size').addEventListener('click', () => {
1416
+ const actual = $('#viewer-image').classList.toggle('actual')
1417
+ $('#baseline-image').classList.toggle('actual', actual)
1418
+ $('.evidence-frame').classList.toggle('actual', actual)
1419
+ $('#viewer-size').setAttribute('aria-pressed', String(actual))
1420
+ $('#viewer-size').textContent = actual ? 'Fit to width' : 'Actual size'
1421
+ })
1422
+ $('#viewer-image').addEventListener('error', () => {
1423
+ $('#viewer-image').hidden = true
1424
+ $('#diagnostic-overlay').setAttribute('hidden', '')
1425
+ $('#viewer-download').removeAttribute('href')
1426
+ $('#viewer-download').setAttribute('aria-disabled', 'true')
1427
+ $('#viewer-download').tabIndex = -1
1428
+ $('#viewer-error').hidden = false
1429
+ })
1430
+ document.addEventListener('keydown', (event) => {
1431
+ if (event.metaKey || event.ctrlKey || event.altKey) return
1432
+ if ($('#lightbox').open) {
1433
+ if (
1434
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName) ||
1435
+ event.target.isContentEditable
1436
+ )
1437
+ return
1438
+ if (event.key === 'ArrowRight') {
1439
+ event.preventDefault()
1440
+ changeViewer(1)
1441
+ }
1442
+ if (event.key === 'ArrowLeft') {
1443
+ event.preventDefault()
1444
+ changeViewer(-1)
1445
+ }
1446
+ return
1447
+ }
1448
+ if (
1449
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName) ||
1450
+ event.target.isContentEditable
1451
+ )
1452
+ return
1453
+ const tile = state.run?.tiles.find((t) => t.id === state.focus)
1454
+ if (event.key === 'j') {
1455
+ event.preventDefault()
1456
+ move(1)
1457
+ } else if (event.key === 'k') {
1458
+ event.preventDefault()
1459
+ move(-1)
1460
+ } else if (event.key === 'a' && tile) setDecision(tile, 'approve')
1461
+ else if (event.key === 'r' && tile) setDecision(tile, 'reject')
1462
+ else if (event.key === 'Enter' && tile && event.target.classList.contains('tile')) {
1463
+ event.preventDefault()
1464
+ openBox(tile)
1465
+ }
1466
+ })
1467
+ function clearFilters() {
1468
+ state.page = 0
1469
+ state.search = ''
1470
+ state.status = ''
1471
+ state.evidence = ''
1472
+ state.viewport = ''
1473
+ state.theme = ''
1474
+ state.onlyFlagged = false
1475
+ state.onlyUnreviewed = false
1476
+ state.onlyChanged = false
1477
+ $('#search').value = ''
1478
+ for (const id of ['#status-filter', '#comparison-filter', '#viewport-filter', '#theme-filter'])
1479
+ $(id).value = ''
1480
+ $('#f-changed').setAttribute('aria-pressed', 'false')
1481
+ $('#f-flagged').setAttribute('aria-pressed', 'false')
1482
+ $('#f-unrev').setAttribute('aria-pressed', 'false')
1483
+ render()
1484
+ }
1485
+ $('#toggle-filters').addEventListener('click', () => {
1486
+ const expanded = $('#toggle-filters').getAttribute('aria-expanded') !== 'true'
1487
+ $('#toggle-filters').setAttribute('aria-expanded', String(expanded))
1488
+ $('.toolbar').dataset.expanded = String(expanded)
1489
+ })
1490
+ for (const [id, key] of [
1491
+ ['#f-changed', 'onlyChanged'],
1492
+ ['#f-flagged', 'onlyFlagged'],
1493
+ ['#f-unrev', 'onlyUnreviewed'],
1494
+ ])
1495
+ $(id).addEventListener('click', (event) => {
1496
+ state.page = 0
1497
+ state[key] = !state[key]
1498
+ if (key === 'onlyChanged') {
1499
+ state.evidence = ''
1500
+ $('#comparison-filter').value = ''
1501
+ }
1502
+ event.currentTarget.setAttribute('aria-pressed', String(state[key]))
1503
+ render()
1504
+ })
1505
+ for (const [id, key] of [
1506
+ ['#status-filter', 'status'],
1507
+ ['#comparison-filter', 'evidence'],
1508
+ ['#viewport-filter', 'viewport'],
1509
+ ['#theme-filter', 'theme'],
1510
+ ])
1511
+ $(id).addEventListener('change', (event) => {
1512
+ state.page = 0
1513
+ state[key] = event.target.value
1514
+ if (key === 'evidence') {
1515
+ state.onlyChanged = false
1516
+ $('#f-changed').setAttribute('aria-pressed', 'false')
1517
+ }
1518
+ render()
1519
+ })
1520
+ $('#search').addEventListener('input', (event) => {
1521
+ state.page = 0
1522
+ state.search = event.target.value.trim().toLowerCase()
1523
+ render()
1524
+ })
1525
+ function changePage(direction) {
1526
+ state.page += direction
1527
+ render()
1528
+ focusTile(visibleTiles()[state.page * state.pageSize]?.id)
1529
+ }
1530
+ for (const button of document.querySelectorAll('[data-page-step]'))
1531
+ button.addEventListener('click', () => changePage(Number(button.dataset.pageStep)))
1532
+ $('#clear-filters').addEventListener('click', clearFilters)
1533
+ $('#next-pending').addEventListener('click', () => {
1534
+ const pending = visibleTiles().filter((t) => !decision(t))
1535
+ const index = pending.findIndex((t) => t.id === state.focus)
1536
+ focusTile(pending[(index + 1) % pending.length]?.id)
1537
+ })
1538
+ $('#export').addEventListener('click', () => {
1539
+ if (
1540
+ !state.run ||
1541
+ state.drafts.size ||
1542
+ state.pending.size ||
1543
+ state.failures.size ||
1544
+ state.comparisonLoading
1545
+ )
1546
+ return
1547
+ const report = {
1548
+ exportedAt: new Date().toISOString(),
1549
+ run: state.run,
1550
+ review: { tiles: state.review },
1551
+ critique: { tiles: state.critique },
1552
+ comparison: {
1553
+ status: state.comparisonFailed ? 'unavailable' : 'complete',
1554
+ comparedAt: state.comparedAt,
1555
+ threshold: state.threshold,
1556
+ tiles: state.comparison,
1557
+ },
1558
+ }
1559
+ const url = URL.createObjectURL(
1560
+ new Blob([JSON.stringify(report, null, 2) + '\n'], { type: 'application/json' }),
1561
+ )
1562
+ const link = el('a')
1563
+ link.href = url
1564
+ link.download = 'design-spine-' + state.run.id.replace(/[^a-zA-Z0-9_-]/g, '-') + '.json'
1565
+ link.click()
1566
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
1567
+ setSaveStatus('Review report downloaded. Baselines have not changed.')
1568
+ })
1569
+ window.addEventListener('beforeunload', (event) => {
1570
+ if (state.drafts.size || state.pending.size || state.failures.size) {
1571
+ event.preventDefault()
1572
+ event.returnValue = ''
1573
+ }
1574
+ })
1575
+ load()