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.
- package/README.md +29 -10
- package/bin/resolve-bun.d.ts +23 -0
- package/bin/resolve-bun.js +66 -0
- package/bin/spine.js +18 -6
- package/package.json +21 -10
- package/src/assets.d.ts +10 -0
- package/src/cli.ts +92 -33
- package/src/lib/capture.ts +3 -1
- package/src/lib/docker.ts +196 -0
- package/src/lib/routes.ts +2 -0
- package/src/lib/server.ts +14 -0
- package/src/lib/summary.ts +70 -0
- package/src/ui/index.html +3 -710
- package/src/ui/review.css +1313 -0
- package/src/ui/review.js +1575 -0
package/src/ui/review.js
ADDED
|
@@ -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()
|