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 +3 -2
- package/bin/resolve-bun.d.ts +1 -1
- package/bin/resolve-bun.js +1 -1
- package/package.json +18 -16
- package/src/cli.ts +7 -1
- package/src/lib/capture.ts +61 -10
- package/src/lib/redact.ts +26 -0
- package/src/lib/report.ts +4 -0
- package/src/lib/runs.ts +9 -3
- package/src/lib/types.ts +9 -0
- package/src/mcp.ts +117 -0
- package/src/ui/index.html +1 -1
- package/src/ui/review.js +88 -71
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.
|
|
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.
|
|
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
|
|
package/bin/resolve-bun.d.ts
CHANGED
|
@@ -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
|
package/bin/resolve-bun.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
56
|
-
"@types/bun": "
|
|
57
|
-
"@types/pngjs": "
|
|
58
|
-
"typescript": "
|
|
59
|
-
"vitest": "
|
|
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.
|
|
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)
|
package/src/lib/capture.ts
CHANGED
|
@@ -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.
|
|
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]
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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 <kbd>a</kbd> approve <kbd>r</kbd> reject <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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
71
|
-
if (t.
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 = $(
|
|
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
|
|
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
|
|
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
|
|
280
|
-
for (const run of runs) select.add(new Option(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') ?
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
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
|
|
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 =
|
|
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
|
|
530
|
-
: matching
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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',
|
|
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(
|
|
616
|
-
img.alt = t.route
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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',
|
|
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 =
|
|
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,
|
|
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 =
|
|
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(
|
|
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(
|
|
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',
|
|
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(
|
|
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',
|
|
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
|
|
1118
|
+
$('#viewer-title').textContent = `${tile.route} · ${tile.state}`
|
|
1105
1119
|
$('#viewer-position').textContent =
|
|
1106
|
-
tile.viewport
|
|
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(
|
|
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
|
-
?
|
|
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 =
|
|
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
|
-
?
|
|
1182
|
+
? ` · ${(comparison.ratio * 100).toFixed(3)}% pixels differ`
|
|
1169
1183
|
: '') +
|
|
1170
|
-
(comparison.baselineRun ?
|
|
1171
|
-
(comparison.approvedAt ?
|
|
1184
|
+
(comparison.baselineRun ? ` · baseline ${comparison.baselineRun}` : '') +
|
|
1185
|
+
(comparison.approvedAt ? ` · approved ${comparison.approvedAt}` : '') +
|
|
1172
1186
|
(comparison.ignored?.length
|
|
1173
|
-
?
|
|
1187
|
+
? ` · ${comparison.ignored.length} comparison masks; original evidence retained`
|
|
1174
1188
|
: '') +
|
|
1175
1189
|
(comparison.recipeCompatible === false ? ' · capture recipe differs or unknown' : '') +
|
|
1176
|
-
(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(
|
|
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(
|
|
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(
|
|
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)
|
|
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 =
|
|
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.')
|