design-spine 0.2.0 → 0.2.1

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 CHANGED
@@ -72,7 +72,7 @@ In CI, add the action after the step that starts your app, and commit `design-sp
72
72
 
73
73
  ```yaml
74
74
  - run: npm run build && (npm start &) && npx wait-on http://localhost:3000
75
- - uses: forbiddenlink/design-spine@v0.2.0
75
+ - uses: forbiddenlink/design-spine@v0.2.1
76
76
  with:
77
77
  base-url: http://localhost:3000
78
78
  ```
@@ -86,7 +86,7 @@ permissions:
86
86
  contents: read
87
87
  pull-requests: write
88
88
  steps:
89
- - uses: forbiddenlink/design-spine@v0.2.0
89
+ - uses: forbiddenlink/design-spine@v0.2.1
90
90
  with:
91
91
  comment: "true"
92
92
  ```
@@ -111,6 +111,7 @@ Upgrading from v0.1.0: that version captured with the runner's own Chromium and
111
111
  | `report` | Self-contained HTML record of a review |
112
112
  | `lint` | Ratchet on arbitrary Tailwind values such as `w-[37px]` |
113
113
  | `status` | Writes `.design-spine/status.json` for dashboards |
114
+ | `mcp` | Read-only MCP server over stdio for agents; see [docs/USAGE.md](docs/USAGE.md#use-from-an-mcp-client) |
114
115
 
115
116
  Full reference: [docs/USAGE.md](docs/USAGE.md).
116
117
 
@@ -17,7 +17,7 @@ export function resolveBun(options?: {
17
17
  run?: (
18
18
  command: string,
19
19
  args: string[],
20
- options: { encoding: 'utf8' },
20
+ options: { encoding: 'utf8'; timeout: number },
21
21
  ) => { error?: Error; status: number | null; stdout?: string | null }
22
22
  bundled?: () => string | undefined
23
23
  }): string | undefined
@@ -51,7 +51,7 @@ export function bundledBunPath({
51
51
  }
52
52
 
53
53
  function bunVersion(command, run) {
54
- const result = run(command, ['--version'], { encoding: 'utf8' })
54
+ const result = run(command, ['--version'], { encoding: 'utf8', timeout: 10000 })
55
55
  if (result.error || result.status !== 0) return undefined
56
56
  return result.stdout ?? ''
57
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-spine",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Screenshot every route and state of your Next.js app, then approve each screen by eye. Local, no account.",
5
5
  "license": "MIT",
6
6
  "author": "Elizabeth Stein",
@@ -26,6 +26,7 @@
26
26
  "README.md",
27
27
  "LICENSE"
28
28
  ],
29
+ "packageManager": "pnpm@12.6.0",
29
30
  "engines": {
30
31
  "node": ">=22.12",
31
32
  "bun": ">=1.3.13"
@@ -34,8 +35,18 @@
34
35
  "bin": {
35
36
  "spine": "bin/spine.js"
36
37
  },
38
+ "scripts": {
39
+ "build": "bun build --compile src/cli.ts src/lib/comparison-worker.ts --external 'chromium-bidi/*' --external electron --define 'import.meta.spineWorker=\"./lib/comparison-worker.js\"' --outfile dist/spine",
40
+ "spine": "bun src/cli.ts",
41
+ "test": "vitest run",
42
+ "test:visual": "bun scripts/visual-tests.ts",
43
+ "test:visual:update": "bun scripts/visual-tests.ts --update",
44
+ "lint": "biome check .",
45
+ "format": "biome format --write ."
46
+ },
37
47
  "dependencies": {
38
48
  "@axe-core/playwright": "^4.13.0",
49
+ "@modelcontextprotocol/sdk": "1.32.2",
39
50
  "commander": "^15.0.0",
40
51
  "pixelmatch": "^7.2.0",
41
52
  "playwright": "^1.63.0",
@@ -52,19 +63,10 @@
52
63
  "@oven/bun-windows-x64": "1.3.13"
53
64
  },
54
65
  "devDependencies": {
55
- "@biomejs/biome": "^2.5.15",
56
- "@types/bun": "^1.4.2",
57
- "@types/pngjs": "^6.0.5",
58
- "typescript": "^7.0.2",
59
- "vitest": "^5.0.3"
60
- },
61
- "scripts": {
62
- "build": "bun build --compile src/cli.ts src/lib/comparison-worker.ts --external 'chromium-bidi/*' --external electron --define 'import.meta.spineWorker=\"./lib/comparison-worker.js\"' --outfile dist/spine",
63
- "spine": "bun src/cli.ts",
64
- "test": "vitest run",
65
- "test:visual": "bun scripts/visual-tests.ts",
66
- "test:visual:update": "bun scripts/visual-tests.ts --update",
67
- "lint": "biome check .",
68
- "format": "biome format --write ."
66
+ "@biomejs/biome": "2.6.4",
67
+ "@types/bun": "1.4.4",
68
+ "@types/pngjs": "6.0.5",
69
+ "typescript": "7.0.2",
70
+ "vitest": "5.0.5"
69
71
  }
70
- }
72
+ }
package/src/cli.ts CHANGED
@@ -54,11 +54,12 @@ import {
54
54
  import { startReviewServer } from './lib/server.ts'
55
55
  import { buildSummary } from './lib/summary.ts'
56
56
  import type { Provenance } from './lib/types.ts'
57
+ import { runMcp } from './mcp.ts'
57
58
 
58
59
  const root = resolve(process.env.SPINE_CWD ?? process.cwd())
59
60
  const program = new Command('spine')
60
61
  .description('Design gate for Next.js repos')
61
- .version('0.2.0')
62
+ .version('0.2.1')
62
63
  // Usage errors exit 2 so they never look like doctor's exit 1 (unfinished coverage).
63
64
  .exitOverride((err) => process.exit(err.exitCode === 0 ? 0 : 2))
64
65
 
@@ -635,4 +636,9 @@ program
635
636
  }),
636
637
  )
637
638
 
639
+ program
640
+ .command('mcp')
641
+ .description('Serve a read-only MCP server over stdio (spine_status, spine_list_tiles)')
642
+ .action(guard(() => runMcp(root)))
643
+
638
644
  await program.parseAsync(process.argv)
@@ -3,11 +3,12 @@ import { createHash } from 'node:crypto'
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import AxeBuilder from '@axe-core/playwright'
6
- import { type Browser, type BrowserContext, chromium } from 'playwright'
6
+ import { type Browser, type BrowserContext, chromium, type Request } from 'playwright'
7
7
  import { PNG } from 'pngjs'
8
8
  import { findBlankBoxes } from './blank.ts'
9
9
  import { fingerprint, validateManifest } from './manifest.ts'
10
10
  import { routeSlug, tileFilename, tileId } from './names.ts'
11
+ import { redactUrls, safeUrl } from './redact.ts'
11
12
  import { fillParams } from './routes.ts'
12
13
  import { setLatest, spineDir, writeJson } from './runs.ts'
13
14
  import type {
@@ -265,6 +266,10 @@ function fixedOverlays(): string[] {
265
266
  )
266
267
  }
267
268
 
269
+ export function requestFailureCode(errorText?: string): string {
270
+ return errorText?.match(/^net::ERR_[A-Z0-9_]+\b/)?.[0] ?? 'Network request failed'
271
+ }
272
+
268
273
  /** Wait before retrying a 429: Retry-After seconds (max 10), else 2s. */
269
274
  export function retryDelayMs(retryAfter?: string): number {
270
275
  const seconds = retryAfter?.trim() ? Number(retryAfter) : Number.NaN
@@ -340,6 +345,7 @@ async function captureTile(
340
345
  textClipped: [],
341
346
  }
342
347
  let ctx: BrowserContext | undefined
348
+ let collectingRequests = true
343
349
  let stage = 'context initialization'
344
350
  try {
345
351
  ctx = await browser.newContext({
@@ -354,22 +360,52 @@ async function captureTile(
354
360
  const page = await ctx.newPage()
355
361
  page.setDefaultTimeout(manifest.capture?.timeoutMs ?? 5000)
356
362
  if (manifest.capture?.clock) await page.clock.setFixedTime(new Date(manifest.capture.clock))
363
+ // Requests answered by a state mock carry the status the author chose, so they are not
364
+ // request failures even when that status is 500.
365
+ const mocked = new WeakSet<Request>()
357
366
  for (const response of job.state.responses ?? []) {
358
- await page.route(response.url, (route) =>
359
- route.fulfill({
367
+ await page.route(response.url, (route) => {
368
+ mocked.add(route.request())
369
+ return route.fulfill({
360
370
  status: response.status,
361
371
  body: response.body ?? '',
362
372
  contentType: response.contentType ?? 'text/plain',
363
- }),
364
- )
373
+ })
374
+ })
365
375
  }
366
376
  page.on('console', (m) => {
367
377
  if (m.type() === 'error' && tile.consoleErrors.length < 20)
368
- tile.consoleErrors.push(m.text().slice(0, 200))
378
+ tile.consoleErrors.push(redactUrls(m.text()).slice(0, 200))
369
379
  })
370
380
  page.on('pageerror', (e) => {
371
- if (tile.pageErrors.length < 20) tile.pageErrors.push(e.message.slice(0, 200))
381
+ if (tile.pageErrors.length < 20) tile.pageErrors.push(redactUrls(e.message).slice(0, 200))
372
382
  })
383
+ const navigationStarted = performance.now()
384
+ const recordedRequests = new WeakSet<Request>()
385
+ const requestErrors: NonNullable<Tile['requestErrors']> = []
386
+ tile.requestErrors = requestErrors
387
+ const recordRequestError = (request: Request, status?: number): void => {
388
+ if (
389
+ !collectingRequests ||
390
+ mocked.has(request) ||
391
+ recordedRequests.has(request) ||
392
+ requestErrors.length >= 20
393
+ )
394
+ return
395
+ recordedRequests.add(request)
396
+ requestErrors.push({
397
+ url: safeUrl(request.url()).slice(0, 500),
398
+ resourceType: request.resourceType(),
399
+ ...(status !== undefined
400
+ ? { status }
401
+ : { failure: requestFailureCode(request.failure()?.errorText) }),
402
+ elapsedMs: Math.round(performance.now() - navigationStarted),
403
+ })
404
+ }
405
+ page.on('response', (response) => {
406
+ if (response.status() >= 400) recordRequestError(response.request(), response.status())
407
+ })
408
+ page.on('requestfailed', (request) => recordRequestError(request))
373
409
  // `load` first, then a bounded wait for network idle. Pages that poll (live
374
410
  // embeds) never go idle; waiting on networkidle alone timed out and lost the tile.
375
411
  const url = manifest.baseUrl.replace(/\/$/, '') + job.path
@@ -377,8 +413,22 @@ async function captureTile(
377
413
  page.goto(url, { waitUntil: 'load', timeout: 30000 })
378
414
  let resp = await go()
379
415
  if (resp?.status() === 429) {
416
+ const limited = resp.request()
380
417
  await page.waitForTimeout(retryDelayMs(resp.headers()['retry-after']))
418
+ const before = requestErrors.length
381
419
  resp = await go()
420
+ // A recovered retry means the first 429 says nothing about this screen; drop its entry.
421
+ if (resp && resp.status() < 400) {
422
+ const first = requestErrors
423
+ .slice(0, before)
424
+ .findIndex(
425
+ (e) =>
426
+ e.status === 429 &&
427
+ e.resourceType === 'document' &&
428
+ e.url === safeUrl(limited.url()).slice(0, 500),
429
+ )
430
+ if (first !== -1) requestErrors.splice(first, 1)
431
+ }
382
432
  }
383
433
  tile.status = resp?.status() ?? 0
384
434
  await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {
@@ -490,7 +540,7 @@ async function captureTile(
490
540
  }
491
541
  }
492
542
  tile.overflowX = m.overflowX
493
- tile.brokenImages = m.brokenImages
543
+ tile.brokenImages = m.brokenImages.map(redactUrls)
494
544
  tile.textClipped = m.textClipped
495
545
  const boxes = findBlankBoxes(PNG.sync.read(buf), m.candidates).map((b) => ({
496
546
  x: Math.round(b.x),
@@ -502,12 +552,13 @@ async function captureTile(
502
552
  tile.consoleErrors = [...new Set(tile.consoleErrors)].slice(0, 5)
503
553
  tile.pageErrors = [...new Set(tile.pageErrors)].slice(0, 5)
504
554
  } catch (e) {
505
- tile.error = (e as Error).message.split('\n')[0]?.slice(0, 200)
555
+ tile.error = redactUrls((e as Error).message.split('\n')[0] ?? '').slice(0, 200)
506
556
  tile.diagnostic = {
507
557
  stage,
508
558
  message: `Capture failed during ${stage}. Inspect the configured condition and retry.`,
509
559
  }
510
560
  } finally {
561
+ collectingRequests = false
511
562
  await ctx?.close().catch(() => {})
512
563
  }
513
564
  return tile
@@ -559,7 +610,7 @@ export async function runCapture(
559
610
  id,
560
611
  startedAt: startedAt.toISOString(),
561
612
  finishedAt: new Date().toISOString(),
562
- baseUrl: opts.recordedBaseUrl ?? manifest.baseUrl,
613
+ baseUrl: redactUrls(opts.recordedBaseUrl ?? manifest.baseUrl),
563
614
  tiles,
564
615
  coverage: coverage.map((entry) => {
565
616
  if (entry.outcome !== 'planned') return entry
@@ -0,0 +1,26 @@
1
+ // Captured text lands in run.json, the HTML report and CI artifacts, so app-generated URLs keep
2
+ // only origin and path: query strings, fragments and userinfo often carry tokens or signatures.
3
+
4
+ /** One URL reduced to origin + path; schemes other than HTTP and WebSocket keep only the scheme. */
5
+ export function safeUrl(raw: string): string {
6
+ let url: URL
7
+ try {
8
+ url = new URL(raw)
9
+ } catch {
10
+ return '[unparseable url]'
11
+ }
12
+ return ['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)
13
+ ? url.origin + url.pathname
14
+ : `${url.protocol}[redacted]`
15
+ }
16
+
17
+ const URL_IN_TEXT = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/g
18
+ const TRAILING = /[.,;:!?)\]]+$/
19
+
20
+ /** Every http(s) and ws(s) URL inside free text (console messages, error strings) passed through safeUrl. */
21
+ export function redactUrls(text: string): string {
22
+ return text.replace(URL_IN_TEXT, (match) => {
23
+ const tail = match.match(TRAILING)?.[0] ?? ''
24
+ return safeUrl(match.slice(0, match.length - tail.length)) + tail
25
+ })
26
+ }
package/src/lib/report.ts CHANGED
@@ -30,6 +30,10 @@ export function buildReport(dir: string, run: RunRecord, review: ReviewFile): st
30
30
  ...(tile.warnings ?? []),
31
31
  ...(tile.consoleErrors ?? []).map((message) => `Console: ${message}`),
32
32
  ...(tile.pageErrors ?? []).map((message) => `Page error: ${message}`),
33
+ ...(tile.requestErrors ?? []).map(
34
+ (error) =>
35
+ `Request: ${error.status !== undefined ? `HTTP ${error.status}` : error.failure} · ${error.resourceType} · +${error.elapsedMs} ms · ${error.url}`,
36
+ ),
33
37
  ...(tile.brokenImages ?? []).map((source) => `Broken image: ${source}`),
34
38
  ...(tile.textClipped ?? []).map((item) => `Possible clipped text: ${item.selector}`),
35
39
  ...(tile.overflowX ? ['Horizontal overflow'] : []),
package/src/lib/runs.ts CHANGED
@@ -105,9 +105,14 @@ export function setLatest(root: string, runId: string): void {
105
105
  }
106
106
 
107
107
  export const loadRun = (dir: string): RunRecord => {
108
- const r = readJson<RunRecord>(join(dir, 'run.json'))
109
- if (!r) throw new Error(`Missing run.json in ${dir}`)
110
- for (const tile of r.tiles ?? []) {
108
+ const path = join(dir, 'run.json')
109
+ const r = readStateJson<RunRecord>(path)
110
+ if (r === null && !existsSync(path)) throw new Error(`Missing run.json in ${dir}`)
111
+ // Callers index r.tiles directly, so a parseable file with the wrong shape is corruption too.
112
+ if (!isPlainObject(r) || (r.tiles !== undefined && !Array.isArray(r.tiles)))
113
+ throw new Error(`Invalid ${path}. Preserve the file and repair or remove it, then retry.`)
114
+ r.tiles ??= []
115
+ for (const tile of r.tiles) {
111
116
  // Tile files are joined onto run and baseline dirs; only plain capture names are allowed.
112
117
  if (typeof tile.file !== 'string' || !/^[A-Za-z0-9[\]._-]+\.png$/.test(tile.file))
113
118
  throw new Error(`Invalid tile file in ${join(dir, 'run.json')}: ${String(tile.file)}`)
@@ -160,4 +165,5 @@ export const isFlagged = (t: Tile): boolean =>
160
165
  t.brokenImages.length > 0 ||
161
166
  t.consoleErrors.length > 0 ||
162
167
  t.pageErrors.length > 0 ||
168
+ !!t.requestErrors?.length ||
163
169
  !!t.redirectedTo
package/src/lib/types.ts CHANGED
@@ -66,6 +66,15 @@ export interface Tile {
66
66
  status: number
67
67
  consoleErrors: string[]
68
68
  pageErrors: string[]
69
+ /** First 20 failed requests observed during capture; URL credentials/query/fragment removed. */
70
+ requestErrors?: {
71
+ url: string
72
+ resourceType: string
73
+ status?: number
74
+ failure?: string
75
+ /** Milliseconds from the start of navigation until the error was observed. */
76
+ elapsedMs: number
77
+ }[]
69
78
  overflowX: boolean
70
79
  blankRegions: { count: number; boxes: Box[] }
71
80
  brokenImages: string[]
package/src/mcp.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { join } from 'node:path'
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
5
+ import { isFlagged, loadReview, loadRun, resolveRunDir, runIdOf } from './lib/runs.ts'
6
+ import type { ReviewFile, RunRecord } from './lib/types.ts'
7
+
8
+ /**
9
+ * Read-only MCP server. It only calls loaders that read (resolveRunDir, loadRun,
10
+ * loadReview) and registers no tool that writes, so the process cannot change a
11
+ * review decision. Do not import writeJson, mergeReview or setLatest here.
12
+ */
13
+
14
+ const STATUSES = ['approved', 'rejected', 'pending'] as const
15
+ type TileStatus = (typeof STATUSES)[number]
16
+
17
+ const statusOf = (review: ReviewFile, id: string): TileStatus => {
18
+ const d = review.tiles[id]?.decision
19
+ return d === 'approve' ? 'approved' : d === 'reject' ? 'rejected' : 'pending'
20
+ }
21
+
22
+ function latest(root: string): { dir: string; run: RunRecord; review: ReviewFile } {
23
+ const dir = resolveRunDir(root)
24
+ return { dir, run: loadRun(dir), review: loadReview(dir) }
25
+ }
26
+
27
+ export function spineStatus(root: string) {
28
+ const { dir, run, review } = latest(root)
29
+ const counts = { approved: 0, rejected: 0, pending: 0 }
30
+ for (const t of run.tiles) counts[statusOf(review, t.id)]++
31
+ return {
32
+ runId: run.id,
33
+ startedAt: run.startedAt,
34
+ finishedAt: run.finishedAt,
35
+ tiles: run.tiles.length,
36
+ ...counts,
37
+ flagged: run.tiles.filter(isFlagged).length,
38
+ runDir: dir,
39
+ reviewCommand: `spine review --run ${runIdOf(dir)}`,
40
+ }
41
+ }
42
+
43
+ export function spineListTiles(root: string, status?: TileStatus) {
44
+ const { dir, run, review } = latest(root)
45
+ return run.tiles
46
+ .map((t) => ({
47
+ id: t.id,
48
+ route: t.route,
49
+ state: t.state,
50
+ viewport: t.viewport,
51
+ theme: t.theme,
52
+ imagePath: join(dir, t.file),
53
+ imageHash: t.imageHash ?? null,
54
+ status: statusOf(review, t.id),
55
+ }))
56
+ .filter((row) => !status || row.status === status)
57
+ }
58
+
59
+ const TOOLS = [
60
+ {
61
+ name: 'spine_status',
62
+ description:
63
+ 'Summary of the latest design-spine run: run id and times, tile count, approved/rejected/pending counts, flagged count, run directory and the command that opens the contact sheet. Read-only.',
64
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
65
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
66
+ },
67
+ {
68
+ name: 'spine_list_tiles',
69
+ description:
70
+ 'List tiles of the latest run with route, state, viewport, theme, image path, image hash (null if not recorded) and review status. Optional status filter. Read-only.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: { status: { type: 'string', enum: [...STATUSES] } },
74
+ additionalProperties: false,
75
+ },
76
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
77
+ },
78
+ ]
79
+
80
+ const ok = (data: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(data) }] })
81
+ const err = (message: string) => ({
82
+ isError: true,
83
+ content: [{ type: 'text' as const, text: message }],
84
+ })
85
+
86
+ export function createSpineMcpServer(root: string): Server {
87
+ const server = new Server(
88
+ { name: 'design-spine', version: '0.2.1' },
89
+ { capabilities: { tools: {} } },
90
+ )
91
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
92
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
93
+ try {
94
+ const args = (req.params.arguments ?? {}) as Record<string, unknown>
95
+ switch (req.params.name) {
96
+ case 'spine_status':
97
+ return ok(spineStatus(root))
98
+ case 'spine_list_tiles': {
99
+ const status = args.status
100
+ if (status !== undefined && !STATUSES.includes(status as TileStatus))
101
+ return err(`status must be one of: ${STATUSES.join(', ')}`)
102
+ return ok(spineListTiles(root, status as TileStatus | undefined))
103
+ }
104
+ default:
105
+ return err(`Unknown tool: ${req.params.name}`)
106
+ }
107
+ } catch (e) {
108
+ return err((e as Error).message)
109
+ }
110
+ })
111
+ return server
112
+ }
113
+
114
+ /** Serve over stdio. stdout carries the protocol, so nothing else may print to it. */
115
+ export async function runMcp(root: string): Promise<void> {
116
+ await createSpineMcpServer(root).connect(new StdioServerTransport())
117
+ }
package/src/ui/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  <details id="coverage" class="diagnostics"><summary id="coverage-summary">Coverage unknown</summary><div id="coverage-details"></div></details>
22
22
  <div class="stats" id="stats" role="group" aria-label="Run summary"></div>
23
23
  <div class="progress-row"><span id="progress-label">Loading review progress</span><progress id="progress" value="0" max="1" aria-label="Reviewed screenshots"></progress><span id="percent">0%</span></div>
24
- <div class="toolbar"><label class="field search-field">Search routes or states<input id="search" type="search" placeholder="Find a route, e.g. /pricing" autocomplete="off"></label><button class="button" id="toggle-filters" aria-expanded="false" aria-controls="secondary-filters">Filters</button><div id="secondary-filters" class="secondary-filters"><label class="field">Review status<select id="status-filter"><option value="">All decisions</option><option value="approve">Approved</option><option value="reject">Rejected</option></select></label><label class="field">Comparison<select id="comparison-filter"><option value="">All evidence</option><option value="new">New · no baseline</option><option value="changed">Changed</option><option value="size-changed">Dimensions changed</option><option value="unchanged">Within threshold</option><option value="no-capture">No capture</option><option value="unavailable">Unavailable</option></select></label><label class="field">Viewport<select id="viewport-filter"><option value="">All widths</option></select></label><label class="field">Capture theme<select id="theme-filter"><option value="">All themes</option><option value="light">Light</option><option value="dark">Dark</option></select></label><div class="filters"><button class="pill" id="f-flagged" aria-pressed="false">Only flagged</button><button class="pill" id="f-changed" aria-pressed="false">Only changed</button><button class="pill" id="f-unrev" aria-pressed="false">Only unreviewed</button><button class="button" id="clear-filters">Clear filters</button></div></div></div>
24
+ <div class="toolbar"><label class="field search-field">Search routes or states<input id="search" type="search" placeholder="Find a route, e.g. /pricing" autocomplete="off"></label><button class="button" id="toggle-filters" aria-expanded="false" aria-controls="secondary-filters">Filters</button><div id="secondary-filters" class="secondary-filters"><label class="field">Review status<select id="status-filter"><option value="">All decisions</option><option value="approve">Approved</option><option value="reject">Rejected</option></select></label><label class="field">Comparison<select id="comparison-filter"><option value="">All evidence</option><option value="new">New · no baseline</option><option value="changed">Changed</option><option value="size-changed">Dimensions changed</option><option value="unchanged">Within threshold</option><option value="no-capture">No capture</option><option value="unavailable">Unavailable</option></select></label><label class="field">Viewport<select id="viewport-filter"><option value="">All widths</option></select></label><label class="field">Capture theme<select id="theme-filter"><option value="">All themes</option><option value="light">Light</option><option value="dark">Dark</option></select></label><div class="filters"><button class="pill" id="f-flagged" aria-pressed="false">Only flagged</button><button class="pill" id="f-warnings" aria-pressed="false">Only capture warnings</button><button class="pill" id="f-changed" aria-pressed="false">Only changed</button><button class="pill" id="f-unrev" aria-pressed="false">Only unreviewed</button><button class="button" id="clear-filters">Clear filters</button></div></div></div>
25
25
  <div class="results-row"><span id="results" aria-live="polite">Loading captures…</span><div class="keys"><kbd>j</kbd>/<kbd>k</kbd> move &nbsp; <kbd>a</kbd> approve &nbsp; <kbd>r</kbd> reject &nbsp; <kbd>Enter</kbd> inspect</div></div>
26
26
  <nav id="pagination" class="pagination" aria-label="Capture pages" hidden><button class="button" id="page-prev" data-page-step="-1" aria-label="Previous page">Previous</button><span id="page-label" class="page-label" aria-live="polite"></span><button class="button" id="page-next" data-page-step="1" aria-label="Next page">Next</button></nav>
27
27
  <div id="save-status" role="status" aria-live="polite"></div>
package/src/ui/review.js CHANGED
@@ -13,6 +13,7 @@ const state = {
13
13
  pageSize: 48,
14
14
  viewerMode: 'current',
15
15
  onlyFlagged: false,
16
+ onlyWarnings: false,
16
17
  onlyUnreviewed: false,
17
18
  search: '',
18
19
  status: '',
@@ -54,21 +55,23 @@ function flags(t) {
54
55
  const f = []
55
56
  if (t.accessibility?.error) f.push(t.accessibility.error)
56
57
  if (t.accessibility?.violations.length)
57
- f.push(t.accessibility.violations.length + ' automated accessibility findings')
58
+ f.push(`${t.accessibility.violations.length} automated accessibility findings`)
58
59
  if (t.stability === 'unstable') f.push('Capture did not stabilize')
59
- if (t.error) f.push('Capture failed: ' + t.error)
60
+ if (t.error) f.push(`Capture failed: ${t.error}`)
60
61
  if (t.status === 0 && !t.error) f.push('No response')
61
- if (t.status >= 400) f.push('HTTP ' + t.status)
62
+ if (t.status >= 400) f.push(`HTTP ${t.status}`)
62
63
  if (t.overflowX) f.push('Horizontal overflow')
63
64
  if (t.blankRegions.count)
64
- f.push(t.blankRegions.count + ' blank region' + (t.blankRegions.count > 1 ? 's' : ''))
65
+ f.push(`${t.blankRegions.count} blank region${t.blankRegions.count > 1 ? 's' : ''}`)
65
66
  if (t.brokenImages.length)
66
- f.push(t.brokenImages.length + ' broken image' + (t.brokenImages.length > 1 ? 's' : ''))
67
+ f.push(`${t.brokenImages.length} broken image${t.brokenImages.length > 1 ? 's' : ''}`)
67
68
  if (t.consoleErrors.length)
68
- f.push(t.consoleErrors.length + ' console error' + (t.consoleErrors.length > 1 ? 's' : ''))
69
+ f.push(`${t.consoleErrors.length} console error${t.consoleErrors.length > 1 ? 's' : ''}`)
69
70
  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)
71
+ f.push(`${t.pageErrors.length} page error${t.pageErrors.length > 1 ? 's' : ''}`)
72
+ if (t.requestErrors?.length)
73
+ f.push(`${t.requestErrors.length} failed request${t.requestErrors.length > 1 ? 's' : ''}`)
74
+ if (t.redirectedTo) f.push(`Redirected to ${t.redirectedTo}`)
72
75
  return f
73
76
  }
74
77
  const isFlagged = (t) => flags(t).length > 0
@@ -85,12 +88,13 @@ function visibleTiles() {
85
88
  (!state.onlyChanged ||
86
89
  ['new', 'changed', 'size-changed'].includes(state.comparison[t.id]?.status)) &&
87
90
  (!state.onlyFlagged || isFlagged(t)) &&
91
+ (!state.onlyWarnings || t.warnings?.length > 0) &&
88
92
  (!state.onlyUnreviewed || !decision(t)) &&
89
93
  (!state.evidence || state.comparison[t.id]?.status === state.evidence) &&
90
94
  (!state.status || decision(t) === state.status) &&
91
95
  (!state.viewport || String(t.viewport) === state.viewport) &&
92
96
  (!state.theme || t.theme === state.theme) &&
93
- (t.route + ' ' + t.state).toLowerCase().includes(state.search),
97
+ `${t.route} ${t.state}`.toLowerCase().includes(state.search),
94
98
  )
95
99
  .sort(
96
100
  (a, b) =>
@@ -123,7 +127,7 @@ async function load() {
123
127
  showEmpty('Loading your captures…', 'Reading this run’s screenshots and review decisions.')
124
128
  try {
125
129
  const response = await fetch(apiPath('/api/run?compare=false'))
126
- if (!response.ok) throw new Error('The review server returned HTTP ' + response.status + '.')
130
+ if (!response.ok) throw new Error(`The review server returned HTTP ${response.status}.`)
127
131
  const data = await response.json()
128
132
  if (!data.run || !Array.isArray(data.run.tiles))
129
133
  throw new Error('This run does not contain valid capture data.')
@@ -145,7 +149,7 @@ async function load() {
145
149
  nav.replaceChildren()
146
150
  for (const [index, route] of [...new Set(data.run.tiles.map((t) => t.route))].entries()) {
147
151
  const link = el('a', null, route)
148
- link.href = '#route-' + index
152
+ link.href = `#route-${index}`
149
153
  link.dataset.route = route
150
154
  link.append(el('span'))
151
155
  link.addEventListener('click', (event) => {
@@ -154,7 +158,7 @@ async function load() {
154
158
  const position = visibleTiles().findIndex((t) => t.route === route)
155
159
  state.page = Math.floor(Math.max(0, position) / state.pageSize)
156
160
  render()
157
- const section = $('#route-' + index)
161
+ const section = $(`#route-${index}`)
158
162
  if (section) {
159
163
  section.scrollIntoView({ block: 'start' })
160
164
  section.querySelector('h2').focus({ preventScroll: true })
@@ -165,7 +169,7 @@ async function load() {
165
169
  const select = $('#viewport-filter')
166
170
  select.replaceChildren(new Option('All widths', ''))
167
171
  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)))
172
+ select.add(new Option(`${width}px`, String(width)))
169
173
  state.loading = false
170
174
  render()
171
175
  loadComparison(data.comparison)
@@ -180,7 +184,7 @@ async function load() {
180
184
  $('#percent').textContent = 'n/a'
181
185
  showEmpty(
182
186
  'Could not load this run.',
183
- error.message + ' Check that the review server is running, then retry.',
187
+ `${error.message} Check that the review server is running, then retry.`,
184
188
  'Retry loading',
185
189
  load,
186
190
  )
@@ -276,8 +280,8 @@ async function loadHistory() {
276
280
  if (!Array.isArray(runs)) return
277
281
  const current = selectedRun()
278
282
  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))
283
+ if (!current) select.add(new Option(`${state.run.id} · opened run`, ''))
284
+ for (const run of runs) select.add(new Option(`${run.id} · ${run.tiles} captures`, run.id))
281
285
  select.value = current || ''
282
286
  } catch {
283
287
  /* The opened run remains available when history cannot load. */
@@ -318,13 +322,13 @@ function renderCoverage() {
318
322
  ' unfinished · ' +
319
323
  count('excluded') +
320
324
  ' excluded' +
321
- (count('filtered') ? ' · ' + count('filtered') + ' outside this capture’s route filter' : '')
325
+ (count('filtered') ? ` · ${count('filtered')} outside this capture’s route filter` : '')
322
326
  summary.replaceChildren(
323
327
  el('span', 'coverage-long', summaryText),
324
328
  el(
325
329
  'span',
326
330
  'coverage-short',
327
- 'Coverage · ' + count('captured') + ' of ' + coverage.length + ' captured',
331
+ `Coverage · ${count('captured')} of ${coverage.length} captured`,
328
332
  ),
329
333
  )
330
334
  const list = el('ul')
@@ -342,7 +346,7 @@ function renderCoverage() {
342
346
  entry.theme +
343
347
  ': ' +
344
348
  entry.outcome +
345
- (entry.reason ? ': ' + entry.reason : ''),
349
+ (entry.reason ? `: ${entry.reason}` : ''),
346
350
  ),
347
351
  )
348
352
  details.append(list)
@@ -356,7 +360,7 @@ function renderCoverage() {
356
360
  const columns = [
357
361
  ...new Map(
358
362
  coverage.map((entry) => [
359
- entry.viewport + ':' + entry.theme,
363
+ `${entry.viewport}:${entry.theme}`,
360
364
  { viewport: entry.viewport, theme: entry.theme },
361
365
  ]),
362
366
  ).values(),
@@ -369,7 +373,7 @@ function renderCoverage() {
369
373
  label.scope = 'col'
370
374
  headers.append(label)
371
375
  for (const column of columns) {
372
- const header = el('th', null, column.viewport + 'px · ' + column.theme)
376
+ const header = el('th', null, `${column.viewport}px · ${column.theme}`)
373
377
  header.scope = 'col'
374
378
  headers.append(header)
375
379
  }
@@ -392,7 +396,7 @@ function renderCoverage() {
392
396
  }
393
397
  for (const entries of groups.values()) {
394
398
  const row = el('tr')
395
- const label = el('th', null, entries[0].route + ' · ' + entries[0].state)
399
+ const label = el('th', null, `${entries[0].route} · ${entries[0].state}`)
396
400
  label.scope = 'row'
397
401
  row.append(label)
398
402
  for (const column of columns) {
@@ -487,12 +491,11 @@ function renderStats() {
487
491
  state.viewport,
488
492
  state.theme,
489
493
  state.onlyFlagged,
494
+ state.onlyWarnings,
490
495
  state.onlyChanged,
491
496
  state.onlyUnreviewed,
492
497
  ].filter(Boolean).length
493
- $('#toggle-filters').textContent = activeFilters
494
- ? 'Filters (' + activeFilters + ' active)'
495
- : 'Filters'
498
+ $('#toggle-filters').textContent = activeFilters ? `Filters (${activeFilters} active)` : 'Filters'
496
499
  const tiles = state.run.tiles
497
500
  const n = (fn) => tiles.filter(fn).length
498
501
  const stats = $('#stats')
@@ -504,14 +507,14 @@ function renderStats() {
504
507
  ['Rejected', n((t) => decision(t) === 'reject')],
505
508
  ['Pending', n((t) => !decision(t))],
506
509
  ]) {
507
- const box = el('div', 'stat ' + label.toLowerCase())
510
+ const box = el('div', `stat ${label.toLowerCase()}`)
508
511
  box.append(el('b', null, String(value)), el('span', null, label))
509
512
  stats.append(box)
510
513
  }
511
514
  for (const link of document.querySelectorAll('#route-nav a')) {
512
515
  const routeTiles = tiles.filter((t) => t.route === link.dataset.route)
513
516
  link.querySelector('span').textContent =
514
- routeTiles.filter((t) => !!decision(t)).length + '/' + routeTiles.length
517
+ `${routeTiles.filter((t) => !!decision(t)).length}/${routeTiles.length}`
515
518
  }
516
519
  const reviewed = n((t) => !!decision(t))
517
520
  $('#progress').max = tiles.length || 1
@@ -522,12 +525,12 @@ function renderStats() {
522
525
  tiles.length +
523
526
  ' reviewed' +
524
527
  (tiles.length && reviewed === tiles.length ? ' · Captured evidence reviewed' : '')
525
- $('#percent').textContent = (tiles.length ? Math.round((reviewed / tiles.length) * 100) : 0) + '%'
528
+ $('#percent').textContent = `${tiles.length ? Math.round((reviewed / tiles.length) * 100) : 0}%`
526
529
  const matching = visibleTiles().length
527
530
  $('#results').textContent =
528
531
  matching <= state.pageSize
529
- ? matching + ' of ' + tiles.length + ' captures shown'
530
- : matching + ' matching captures · ' + tiles.length + ' total'
532
+ ? `${matching} of ${tiles.length} captures shown`
533
+ : `${matching} matching captures · ${tiles.length} total`
531
534
  updateControls()
532
535
  }
533
536
  function renderTile(t) {
@@ -545,14 +548,14 @@ function renderTile(t) {
545
548
  details.append(el('summary', null, 'Automated accessibility findings'))
546
549
  const list = el('ul')
547
550
  for (const issue of t.accessibility.violations)
548
- list.append(el('li', null, issue.id + ': ' + issue.help + ' · ' + issue.targets.join(', ')))
551
+ list.append(el('li', null, `${issue.id}: ${issue.help} · ${issue.targets.join(', ')}`))
549
552
  details.append(list)
550
553
  more.append(details)
551
- moreParts.push(t.accessibility.violations.length + ' a11y')
554
+ moreParts.push(`${t.accessibility.violations.length} a11y`)
552
555
  }
553
556
  if (t.masks?.length) {
554
557
  const details = el('details', 'diagnostics')
555
- details.append(el('summary', null, 'Comparison masks (' + t.masks.length + ')'))
558
+ details.append(el('summary', null, `Comparison masks (${t.masks.length})`))
556
559
  const list = el('ul')
557
560
  for (const mask of t.masks)
558
561
  list.append(
@@ -580,7 +583,7 @@ function renderTile(t) {
580
583
  )
581
584
  details.append(list)
582
585
  more.append(details)
583
- moreParts.push(t.masks.length + ' masks')
586
+ moreParts.push(`${t.masks.length} masks`)
584
587
  }
585
588
  card.dataset.id = t.id
586
589
  card.tabIndex = 0
@@ -598,13 +601,13 @@ function renderTile(t) {
598
601
  )
599
602
  const head = el('div', 'head')
600
603
  head.append(
601
- el('span', null, t.viewport + 'px · ' + t.theme),
604
+ el('span', null, `${t.viewport}px · ${t.theme}`),
602
605
  el('span', null, d === 'approve' ? 'Approved' : d === 'reject' ? 'Rejected' : 'Pending'),
603
606
  )
604
607
  const thumb = el('div', 'thumb')
605
608
  thumb.tabIndex = 0
606
609
  thumb.setAttribute('role', 'region')
607
- thumb.setAttribute('aria-label', 'Screenshot preview for ' + t.id)
610
+ thumb.setAttribute('aria-label', `Screenshot preview for ${t.id}`)
608
611
  const inspect = el('button', 'button inspect', 'Inspect')
609
612
  inspect.setAttribute('aria-label', 'Inspect screenshot')
610
613
  inspect.disabled = !!t.error
@@ -612,8 +615,8 @@ function renderTile(t) {
612
615
  if (!t.error) {
613
616
  const img = el('img')
614
617
  img.loading = 'lazy'
615
- img.src = apiPath('/img/' + encodeURIComponent(t.file))
616
- img.alt = t.route + ' · ' + t.state + ' · ' + t.viewport + 'px · ' + t.theme
618
+ img.src = apiPath(`/img/${encodeURIComponent(t.file)}`)
619
+ img.alt = `${t.route} · ${t.state} · ${t.viewport}px · ${t.theme}`
617
620
  img.addEventListener('click', () => openBox(t, inspect))
618
621
  img.addEventListener('error', () => {
619
622
  thumb.replaceChildren(
@@ -636,7 +639,7 @@ function renderTile(t) {
636
639
  el(
637
640
  'span',
638
641
  'badge warn',
639
- t.textClipped.length + ' clipped text warning' + (t.textClipped.length > 1 ? 's' : ''),
642
+ `${t.textClipped.length} clipped text warning${t.textClipped.length > 1 ? 's' : ''}`,
640
643
  ),
641
644
  )
642
645
  if (t.warnings?.length)
@@ -644,25 +647,36 @@ function renderTile(t) {
644
647
  el(
645
648
  'span',
646
649
  'badge warn',
647
- t.warnings.length + ' capture warning' + (t.warnings.length > 1 ? 's' : ''),
650
+ `${t.warnings.length} capture warning${t.warnings.length > 1 ? 's' : ''}`,
648
651
  ),
649
652
  )
650
653
  card.append(head, thumb, badges)
651
654
  const diagnostics = [
652
655
  ...t.consoleErrors,
653
656
  ...t.pageErrors,
657
+ ...(t.requestErrors || []).map(
658
+ (error) =>
659
+ 'Request: ' +
660
+ (error.status !== undefined ? `HTTP ${error.status}` : error.failure) +
661
+ ' · ' +
662
+ error.resourceType +
663
+ ' · +' +
664
+ error.elapsedMs +
665
+ ' ms · ' +
666
+ error.url,
667
+ ),
654
668
  ...t.brokenImages,
655
669
  ...(t.warnings || []),
656
- ...t.textClipped.map((x) => x.selector + ': ' + x.text),
670
+ ...t.textClipped.map((x) => `${x.selector}: ${x.text}`),
657
671
  ]
658
672
  if (diagnostics.length) {
659
673
  const details = el('details', 'diagnostics')
660
- details.append(el('summary', null, 'Diagnostic details (' + diagnostics.length + ')'))
674
+ details.append(el('summary', null, `Diagnostic details (${diagnostics.length})`))
661
675
  const list = el('ul')
662
676
  for (const item of diagnostics) list.append(el('li', null, item))
663
677
  details.append(list)
664
678
  more.append(details)
665
- moreParts.push(diagnostics.length + ' diagnostic' + (diagnostics.length > 1 ? 's' : ''))
679
+ moreParts.push(`${diagnostics.length} diagnostic${diagnostics.length > 1 ? 's' : ''}`)
666
680
  }
667
681
  const c = state.critique[t.id]
668
682
  if (c) {
@@ -684,8 +698,8 @@ function renderTile(t) {
684
698
  for (const issue of c.issues) {
685
699
  const item = el('li')
686
700
  item.append(
687
- el('span', 'sev', issue.severity + ' '),
688
- document.createTextNode(issue.what + (issue.where ? ' (' + issue.where + ')' : '')),
701
+ el('span', 'sev', `${issue.severity} `),
702
+ document.createTextNode(issue.what + (issue.where ? ` (${issue.where})` : '')),
689
703
  )
690
704
  list.append(item)
691
705
  }
@@ -695,7 +709,7 @@ function renderTile(t) {
695
709
  badges.append(
696
710
  el(
697
711
  'span',
698
- 'badge' + (c.verdict === 'pass' ? ' ok' : c.verdict === 'fix' ? '' : ' warn'),
712
+ `badge${c.verdict === 'pass' ? ' ok' : c.verdict === 'fix' ? '' : ' warn'}`,
699
713
  c.verdict === 'pass'
700
714
  ? 'Critique: pass'
701
715
  : c.verdict === 'fix'
@@ -723,7 +737,7 @@ function renderTile(t) {
723
737
  const button = el('button', value, label)
724
738
  button.setAttribute('aria-pressed', String(d === value))
725
739
  button.disabled = state.pending.has(t.id)
726
- button.setAttribute('aria-label', label + ' ' + t.id)
740
+ button.setAttribute('aria-label', `${label} ${t.id}`)
727
741
  button.addEventListener('click', () => setDecision(t, value))
728
742
  actions.append(button)
729
743
  }
@@ -732,9 +746,9 @@ function renderTile(t) {
732
746
  note.placeholder = 'What should change?'
733
747
  note.maxLength = 2000
734
748
  note.value = state.drafts.get(t.id) ?? state.review[t.id]?.note ?? ''
735
- note.setAttribute('aria-label', 'Review note for ' + t.id)
749
+ note.setAttribute('aria-label', `Review note for ${t.id}`)
736
750
  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)
751
+ noteHelp.id = `note-help-${state.run.tiles.indexOf(t)}`
738
752
  note.setAttribute('aria-describedby', noteHelp.id)
739
753
  note.addEventListener('input', () => {
740
754
  if (note.value === (state.review[t.id]?.note ?? '') && !state.pending.has(t.id))
@@ -752,7 +766,7 @@ function renderTile(t) {
752
766
  const hasNote = !!(state.drafts.get(t.id) ?? state.review[t.id]?.note)
753
767
  if (hasNote) moreParts.unshift('note')
754
768
  more.prepend(
755
- el('summary', null, 'Note & details' + (moreParts.length ? ' · ' + moreParts.join(' · ') : '')),
769
+ el('summary', null, `Note & details${moreParts.length ? ` · ${moreParts.join(' · ')}` : ''}`),
756
770
  )
757
771
  more.open = hasNote || state.failures.has(t.id)
758
772
  card.append(actions, more)
@@ -813,7 +827,7 @@ function render() {
813
827
  }
814
828
  for (const [route, states] of groups) {
815
829
  const section = el('section', 'route')
816
- section.id = 'route-' + [...new Set(state.run.tiles.map((t) => t.route))].indexOf(route)
830
+ section.id = `route-${[...new Set(state.run.tiles.map((t) => t.route))].indexOf(route)}`
817
831
  const heading = el('h2')
818
832
  heading.tabIndex = -1
819
833
  heading.append(el('code', null, route))
@@ -858,7 +872,7 @@ async function save(t, patch) {
858
872
  headers: { 'content-type': 'application/json' },
859
873
  body: JSON.stringify({ tileId: t.id, imageHash: t.imageHash, ...patch }),
860
874
  })
861
- if (!response.ok) throw new Error('HTTP ' + response.status)
875
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
862
876
  const entry = await response.json()
863
877
  if (
864
878
  !entry ||
@@ -952,7 +966,7 @@ function focusTile(id, scroll = true) {
952
966
  state.page = Math.floor(index / state.pageSize)
953
967
  render()
954
968
  }
955
- const node = id && document.querySelector('.tile[data-id="' + CSS.escape(id) + '"]')
969
+ const node = id && document.querySelector(`.tile[data-id="${CSS.escape(id)}"]`)
956
970
  if (node) {
957
971
  node.focus({ preventScroll: true })
958
972
  if (scroll)
@@ -985,7 +999,7 @@ function updateRegions() {
985
999
  const note = state.review[tile?.id]?.anchor
986
1000
  const pin = $('#note-overlay')
987
1001
  pin.replaceChildren()
988
- pin.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight)
1002
+ pin.setAttribute('viewBox', `0 0 ${img.naturalWidth} ${img.naturalHeight}`)
989
1003
  pin.toggleAttribute(
990
1004
  'hidden',
991
1005
  !note ||
@@ -1013,7 +1027,7 @@ function updateRegions() {
1013
1027
  const selected = select.value
1014
1028
  select.replaceChildren(new Option('Choose a region', ''))
1015
1029
  boxes.forEach((_box, index) => {
1016
- select.add(new Option('Possible blank region ' + (index + 1), String(index)))
1030
+ select.add(new Option(`Possible blank region ${index + 1}`, String(index)))
1017
1031
  })
1018
1032
  select.disabled = !boxes.length
1019
1033
  select.value = selected
@@ -1027,7 +1041,7 @@ function updateRegions() {
1027
1041
  !img.complete ||
1028
1042
  !img.naturalWidth,
1029
1043
  )
1030
- overlay.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight)
1044
+ overlay.setAttribute('viewBox', `0 0 ${img.naturalWidth} ${img.naturalHeight}`)
1031
1045
  boxes.forEach((box, index) => {
1032
1046
  const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
1033
1047
  for (const [name, value] of Object.entries({ x: box.x, y: box.y, width: box.w, height: box.h }))
@@ -1101,9 +1115,9 @@ function updateViewer() {
1101
1115
  history.replaceState(null, '', url)
1102
1116
  $('#viewer-note').value = state.drafts.get(tile.id) ?? state.review[tile.id]?.note ?? ''
1103
1117
  updateViewerControls()
1104
- $('#viewer-title').textContent = tile.route + ' · ' + tile.state
1118
+ $('#viewer-title').textContent = `${tile.route} · ${tile.state}`
1105
1119
  $('#viewer-position').textContent =
1106
- tile.viewport + 'px · ' + tile.theme + ' · ' + (index + 1) + ' of ' + tiles.length
1120
+ `${tile.viewport}px · ${tile.theme} · ${index + 1} of ${tiles.length}`
1107
1121
  const comparison = state.comparison[tile.id] || {}
1108
1122
  const mode = $('#viewer-mode')
1109
1123
  for (const option of mode.options)
@@ -1123,7 +1137,7 @@ function updateViewer() {
1123
1137
  const changes = $('#change-region')
1124
1138
  changes.replaceChildren(new Option('Choose a change', ''))
1125
1139
  ;(comparison.regions || []).forEach((box, index) => {
1126
- changes.add(new Option('Change ' + (index + 1) + ' · y ' + box.y + 'px', String(index)))
1140
+ changes.add(new Option(`Change ${index + 1} · y ${box.y}px`, String(index)))
1127
1141
  })
1128
1142
  changes.disabled = !comparison.regions?.length || view === 'baseline'
1129
1143
  $('#anchor-note').disabled = !tile.imageHash || view !== 'current' || state.pending.has(tile.id)
@@ -1139,7 +1153,7 @@ function updateViewer() {
1139
1153
  $('#save-anchor').disabled = !tile.imageHash || view !== 'current' || state.pending.has(tile.id)
1140
1154
  $('#anchor-status').textContent = anchor
1141
1155
  ? anchor.imageHash === tile.imageHash
1142
- ? 'Note location: ' + anchor.x + ', ' + anchor.y + 'px.'
1156
+ ? `Note location: ${anchor.x}, ${anchor.y}px.`
1143
1157
  : 'Note location belongs to different evidence.'
1144
1158
  : ''
1145
1159
  $('#viewer-size').disabled = view === 'split'
@@ -1152,7 +1166,7 @@ function updateViewer() {
1152
1166
  $('#baseline-pane').hidden = view !== 'split'
1153
1167
  $('.viewer-pair').classList.toggle('split', view === 'split')
1154
1168
  $('#baseline-image').src = view === 'split' ? comparison.baselineUrl : ''
1155
- $('#baseline-image').alt = 'Baseline for ' + tile.route + ', ' + tile.state
1169
+ $('#baseline-image').alt = `Baseline for ${tile.route}, ${tile.state}`
1156
1170
  $('#current-caption').textContent =
1157
1171
  view === 'baseline'
1158
1172
  ? 'Baseline'
@@ -1165,15 +1179,15 @@ function updateViewer() {
1165
1179
  (state.threshold * 100).toFixed(2) +
1166
1180
  '%' +
1167
1181
  (comparison.ratio !== undefined
1168
- ? ' · ' + (comparison.ratio * 100).toFixed(3) + '% pixels differ'
1182
+ ? ` · ${(comparison.ratio * 100).toFixed(3)}% pixels differ`
1169
1183
  : '') +
1170
- (comparison.baselineRun ? ' · baseline ' + comparison.baselineRun : '') +
1171
- (comparison.approvedAt ? ' · approved ' + comparison.approvedAt : '') +
1184
+ (comparison.baselineRun ? ` · baseline ${comparison.baselineRun}` : '') +
1185
+ (comparison.approvedAt ? ` · approved ${comparison.approvedAt}` : '') +
1172
1186
  (comparison.ignored?.length
1173
- ? ' · ' + comparison.ignored.length + ' comparison masks; original evidence retained'
1187
+ ? ` · ${comparison.ignored.length} comparison masks; original evidence retained`
1174
1188
  : '') +
1175
1189
  (comparison.recipeCompatible === false ? ' · capture recipe differs or unknown' : '') +
1176
- (comparison.provenance ? ' · environment ' + comparison.provenance : '') +
1190
+ (comparison.provenance ? ` · environment ${comparison.provenance}` : '') +
1177
1191
  (comparison.status === 'size-changed'
1178
1192
  ? ' · Native dimensions differ; pixel overlay unavailable.'
1179
1193
  : '')
@@ -1195,7 +1209,7 @@ function updateViewer() {
1195
1209
  ? comparison.baselineUrl
1196
1210
  : view === 'diff'
1197
1211
  ? comparison.diffUrl
1198
- : apiPath('/img/' + encodeURIComponent(tile.file))
1212
+ : apiPath(`/img/${encodeURIComponent(tile.file)}`)
1199
1213
  $('#viewer-download').href = img.src
1200
1214
  $('#viewer-download').download = tile.file
1201
1215
  $('#viewer-download').setAttribute('aria-disabled', 'false')
@@ -1359,7 +1373,7 @@ $('#lightbox').addEventListener('close', () => {
1359
1373
  focusTile(viewed, false)
1360
1374
  if (inspect)
1361
1375
  document
1362
- .querySelector('.tile[data-id="' + CSS.escape(viewed) + '"] .inspect')
1376
+ .querySelector(`.tile[data-id="${CSS.escape(viewed)}"] .inspect`)
1363
1377
  ?.focus({ preventScroll: true })
1364
1378
  } else focusFilterReset()
1365
1379
  })
@@ -1394,7 +1408,7 @@ $('#viewer-region').addEventListener('change', () => {
1394
1408
  $('#viewer-regions').textContent = 'Hide blank regions'
1395
1409
  updateRegions()
1396
1410
  $('#diagnostic-overlay')
1397
- .querySelector('[data-region="' + value + '"]')
1411
+ .querySelector(`[data-region="${value}"]`)
1398
1412
  ?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' })
1399
1413
  })
1400
1414
  $('#viewer-refresh').addEventListener('click', async () => {
@@ -1472,6 +1486,7 @@ function clearFilters() {
1472
1486
  state.viewport = ''
1473
1487
  state.theme = ''
1474
1488
  state.onlyFlagged = false
1489
+ state.onlyWarnings = false
1475
1490
  state.onlyUnreviewed = false
1476
1491
  state.onlyChanged = false
1477
1492
  $('#search').value = ''
@@ -1479,6 +1494,7 @@ function clearFilters() {
1479
1494
  $(id).value = ''
1480
1495
  $('#f-changed').setAttribute('aria-pressed', 'false')
1481
1496
  $('#f-flagged').setAttribute('aria-pressed', 'false')
1497
+ $('#f-warnings').setAttribute('aria-pressed', 'false')
1482
1498
  $('#f-unrev').setAttribute('aria-pressed', 'false')
1483
1499
  render()
1484
1500
  }
@@ -1490,6 +1506,7 @@ $('#toggle-filters').addEventListener('click', () => {
1490
1506
  for (const [id, key] of [
1491
1507
  ['#f-changed', 'onlyChanged'],
1492
1508
  ['#f-flagged', 'onlyFlagged'],
1509
+ ['#f-warnings', 'onlyWarnings'],
1493
1510
  ['#f-unrev', 'onlyUnreviewed'],
1494
1511
  ])
1495
1512
  $(id).addEventListener('click', (event) => {
@@ -1557,11 +1574,11 @@ $('#export').addEventListener('click', () => {
1557
1574
  },
1558
1575
  }
1559
1576
  const url = URL.createObjectURL(
1560
- new Blob([JSON.stringify(report, null, 2) + '\n'], { type: 'application/json' }),
1577
+ new Blob([`${JSON.stringify(report, null, 2)}\n`], { type: 'application/json' }),
1561
1578
  )
1562
1579
  const link = el('a')
1563
1580
  link.href = url
1564
- link.download = 'design-spine-' + state.run.id.replace(/[^a-zA-Z0-9_-]/g, '-') + '.json'
1581
+ link.download = `design-spine-${state.run.id.replace(/[^a-zA-Z0-9_-]/g, '-')}.json`
1565
1582
  link.click()
1566
1583
  setTimeout(() => URL.revokeObjectURL(url), 1000)
1567
1584
  setSaveStatus('Review report downloaded. Baselines have not changed.')