@awarebydefault/display-case 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,7 +34,7 @@ The CLI looks for `display-case.config.ts` then `display-case.config.tsx` in the
34
34
  | `baselineDir` | `string` | no | `.display-case/baselines` | Where visual-regression baselines are stored. |
35
35
  | `tokens` | `{ allow?: string[] }` | no | none | Design-token conformance options for `--tokens`. `allow` lists custom-property names the package may reference but does not itself define (e.g. host-app-provided tokens). See [Testing](testing.md#token-conformance). |
36
36
  | `providers` | `{ driver?, diff? }` | no | built-in | Override the visual-regression backend. When unset, the built-in Playwright/axe driver and pixelmatch/pngjs diff are loaded lazily. See [`providers`](#providers). |
37
- | `check` | `{ defaultPhases?, structure? }` | no | none | Tune the `check` command: which phases run by default, and the structure phase's rules and severities. See [`check`](#check). |
37
+ | `check` | `{ defaultPhases?, concurrency?, structure? }` | no | none | Tune the `check` command: which phases run by default, how many variants the a11y/visual phases scan concurrently, and the structure phase's rules and severities. See [`check`](#check). |
38
38
  | `a11y` | `{ enabled?, themes?, exclude?, startup? }` | no | off | Live accessibility surfacing in the running browse chrome. See [`a11y`](#a11y). |
39
39
 
40
40
  ### `title`
@@ -349,6 +349,9 @@ check: {
349
349
  // opt out. An opted-out phase still runs when named explicitly (e.g. --visual).
350
350
  defaultPhases: { visual: false },
351
351
 
352
+ // How many variants the a11y/visual phases scan concurrently. Default 4.
353
+ concurrency: 4,
354
+
352
355
  structure: {
353
356
  // Treat every structure warning as an error for the run (same as --strict).
354
357
  strict: false,
@@ -365,6 +368,7 @@ check: {
365
368
  ```
366
369
 
367
370
  - **`defaultPhases`** — a `Partial<Record<'tokens' | 'a11y' | 'visual' | 'structure', boolean>>`. Drop a phase from the bare `check` run (e.g. `visual` when no baselines are committed) while keeping it available via its flag.
371
+ - **`concurrency`** — how many variants the render phases (a11y/visual) scan at once, each on its own page from a shared browser. Default `4`; override per run with `--concurrency=N`. A custom `providers.driver` must tolerate concurrent `open()` calls (set `1` if it can't). See [Testing → Reporting and concurrency](testing.md#reporting-and-concurrency).
368
372
  - **`structure.rules[id]`** — `false` disables the rule; `'warn'`/`'error'` enables it at that severity; an options object (`{ severity?, ignore?, thresholds? }`) enables it with overrides. Unset ⇒ the rule's default. The rule ids, defaults, and escape-hatch markers are listed in [Testing → Structure checks](testing.md#structure-checks).
369
373
  - **`structure.strict`** — escalate all structure warnings to errors (the config equivalent of `check --strict`).
370
374
 
package/docs/testing.md CHANGED
@@ -139,10 +139,12 @@ Escapes:
139
139
 
140
140
  ## Accessibility
141
141
 
142
- For every case in every theme, the runner analyzes the page with axe-core and reports each violation, followed by the affected nodes for colour-contrast, the failing element and the exact measured-vs-required pair, so a finding is fixable without re-running a browser:
142
+ For every case in every theme, the runner analyzes the page with axe-core. Each variant is reported as a test — `(pass)` or `(fail)`, with its own timing — in the shape of `bun test`, so a CI log can be grepped and summarized the same way a test run is. A failing variant is followed by each violation and its affected nodes; for colour-contrast, the failing element and the exact measured-vs-required pair, so a finding is fixable without re-running a browser:
143
143
 
144
144
  ```
145
- a11y ✗ tweak-control/variants [dark] color-contrast: Elements must meet contrast ratio (2 node(s))
145
+ a11y (pass) eyebrow/tones [light] [270.84ms]
146
+ a11y (fail) tweak-control/variants [dark] [412.30ms]
147
+ serious color-contrast: Elements must meet contrast ratio (2 node(s))
146
148
  ↳ .dcui-tweak-label #8a8073 on #ffffff = 3.87:1 (need 4.5:1) [12.0pt (16px) normal]
147
149
  ↳ .dcui-tweak-url #8a8073 on #ffffff = 3.87:1 (need 4.5:1) [12.0pt (16px) normal]
148
150
  ```
@@ -155,15 +157,17 @@ The isolated render document is a complete page (a `<title>`, `lang`, and a sing
155
157
 
156
158
  ## Visual regression
157
159
 
158
- For every case in every theme, the runner takes a screenshot and compares it to a baseline PNG:
160
+ For every case in every theme, the runner takes a screenshot and compares it to a baseline PNG. Each variant is reported as a `bun test`-style test with its own timing:
159
161
 
160
- - **No baseline yet** → the screenshot is recorded as the new baseline (counted as "recorded", not a failure).
161
- - **Baseline matches** → pass.
162
- - **Baseline differs** → failure. A `<case>.<theme>.diff.png` is written next to the baseline so you can inspect the change.
163
- - **Dimensions changed** → failure. The new render is saved as `<case>.<theme>.actual.png` for inspection.
162
+ - **No baseline yet** → the screenshot is recorded as the new baseline, reported `(record)` (counted as "recorded", not a failure).
163
+ - **Baseline matches** → `(pass)`.
164
+ - **Baseline differs** → `(fail)`. A `<case>.<theme>.diff.png` is written next to the baseline (its path is printed under the failing line) so you can inspect the change.
165
+ - **Dimensions changed** → `(fail)`. The new render is saved as `<case>.<theme>.actual.png` for inspection.
164
166
 
165
167
  ```
166
- visual tweak-control/variants [light] differs from baseline
168
+ visual (pass) eyebrow/tones [light] [87.75ms]
169
+ visual (fail) tweak-control/variants [light] [203.86ms]
170
+ differs from baseline → test/visual-baselines/tweak-control/variants.light.diff.png
167
171
  ```
168
172
 
169
173
  The diff threshold is strict: any differing pixel counts as a change.
@@ -211,6 +215,39 @@ export default defineConfig({
211
215
  > [`check.defaultPhases`](configuration.md)`: { visual: false }`; the phase still
212
216
  > runs when asked explicitly (`--visual`) and in CI. This repo does exactly that.
213
217
 
218
+ ## Reporting and concurrency
219
+
220
+ The a11y and visual phases report in the shape of `bun test`: one `(pass)` /
221
+ `(fail)` / `(record)` line per variant, each tagged with its own elapsed time
222
+ (high-resolution, via `Bun.nanoseconds()`), then a rolled-up summary — per-phase
223
+ counts, the overall `N pass` / `N fail`, and a `Ran N checks [time]` line whose
224
+ time is the **wall-clock** for the whole render run:
225
+
226
+ ```
227
+ a11y 42 pass 0 fail
228
+ visual 40 pass 2 fail 3 recorded
229
+
230
+ 82 pass
231
+ 2 fail
232
+ 3 recorded
233
+ Ran 87 checks [12.41s] (concurrency 4)
234
+ ```
235
+
236
+ Because the variants are scanned concurrently, the wall-clock is well below the
237
+ sum of the per-variant times. The fixed `(pass)`/`(fail)` tags are plain text (no
238
+ colour or glyphs to parse), so a CI step can grep and tally them like any test
239
+ run.
240
+
241
+ The render phases scan multiple variants at once — each on its own page from a
242
+ shared browser — to cut wall-clock. The default concurrency is **4**; override it
243
+ per run with `--concurrency=N` or globally with `check.concurrency` in the config.
244
+ A custom [`providers.driver`](configuration.md#providers) must tolerate
245
+ concurrent `open()` calls (set concurrency to `1` if it can't).
246
+
247
+ ```bash
248
+ display-case check . --a11y --visual --concurrency=8
249
+ ```
250
+
214
251
  ## Change-scoped checks
215
252
 
216
253
  The a11y and visual phases re-render every case, which is wasteful in CI when a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awarebydefault/display-case",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "A Bun-native, AI-friendly component showcase — a lightweight alternative to Storybook.",
5
5
  "license": "MIT",
6
6
  "author": "Jake Uskoski <jake@awarebydefault.com>",
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ emptyTally,
4
+ formatDuration,
5
+ summaryLines,
6
+ testLine,
7
+ } from './check-reporter'
8
+
9
+ const MS = 1e6
10
+ const SEC = 1e9
11
+
12
+ describe('check-reporter: formatDuration', () => {
13
+ test('sub-second spans render as milliseconds with two decimals', () => {
14
+ expect(formatDuration(12.345 * MS)).toBe('12.35ms')
15
+ expect(formatDuration(0)).toBe('0.00ms')
16
+ })
17
+
18
+ test('spans of a second or more render as seconds', () => {
19
+ expect(formatDuration(SEC)).toBe('1.00s')
20
+ expect(formatDuration(3.456 * SEC)).toBe('3.46s')
21
+ })
22
+ })
23
+
24
+ describe('check-reporter: testLine', () => {
25
+ test('a pass carries the (pass) tag, name, and timing', () => {
26
+ expect(testLine('a11y', 'Button/default [light]', 'pass', 12.34 * MS)).toBe(
27
+ ' a11y (pass) Button/default [light] [12.34ms]',
28
+ )
29
+ })
30
+
31
+ test('phases pad to a common width so tags align', () => {
32
+ const a = testLine('a11y', 'X/y [light]', 'fail', MS)
33
+ const v = testLine('visual', 'X/y [light]', 'fail', MS)
34
+ expect(a.indexOf('(fail)')).toBe(v.indexOf('(fail)'))
35
+ })
36
+
37
+ test('a recorded baseline carries the (record) tag', () => {
38
+ expect(testLine('visual', 'X/y [dark]', 'record', MS)).toContain('(record)')
39
+ })
40
+ })
41
+
42
+ describe('check-reporter: summaryLines', () => {
43
+ test('rolls up per-phase tallies, totals, and wall-clock', () => {
44
+ const a11y = { ...emptyTally(), pass: 43, fail: 1 }
45
+ const visual = { ...emptyTally(), pass: 42, fail: 2, record: 3 }
46
+ const lines = summaryLines(
47
+ [
48
+ { phase: 'a11y', tally: a11y },
49
+ { phase: 'visual', tally: visual },
50
+ ],
51
+ 2.5 * SEC,
52
+ 4,
53
+ )
54
+ const text = lines.join('\n')
55
+ expect(text).toContain('a11y 43 pass 1 fail')
56
+ expect(text).toContain('visual 42 pass 2 fail 3 recorded')
57
+ // 43+1 + 42+2+3 = 91 checks; 85 pass / 3 fail overall.
58
+ expect(text).toContain(' 85 pass')
59
+ expect(text).toContain(' 3 fail')
60
+ expect(text).toContain('Ran 91 checks [2.50s] (concurrency 4)')
61
+ })
62
+
63
+ test('a single check is not pluralized', () => {
64
+ const lines = summaryLines(
65
+ [{ phase: 'a11y', tally: { ...emptyTally(), pass: 1 } }],
66
+ MS,
67
+ 1,
68
+ )
69
+ expect(lines.join('\n')).toContain('Ran 1 check [')
70
+ })
71
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pure formatting for the render-phase (a11y + visual) progress output. Models
3
+ * each scanned variant as a "test" that passes or fails, in the shape of `bun
4
+ * test`'s reporter — a `(pass)`/`(fail)` tag, a stable name, and a per-test
5
+ * timing — so a CI log can be grepped and summarized the same way a test run is.
6
+ *
7
+ * Kept side-effect-free (no console, no I/O) so the formatting is unit-tested;
8
+ * `check.ts` owns the timing (via `Bun.nanoseconds()`), the browser, and the
9
+ * actual `console` writes.
10
+ */
11
+
12
+ /** A single scanned variant under one phase — the unit that passes or fails. */
13
+ export type TestStatus = 'pass' | 'fail' | 'record'
14
+
15
+ const STATUS_TAG: Record<TestStatus, string> = {
16
+ pass: '(pass)',
17
+ fail: '(fail)',
18
+ record: '(record)',
19
+ }
20
+
21
+ // Phase labels are padded to a common width so the `(pass)`/`(fail)` tags line
22
+ // up into a scannable column regardless of which phase emitted the line.
23
+ const PHASE_WIDTH = 6
24
+
25
+ /**
26
+ * Human duration from a nanosecond span (`Bun.nanoseconds()` deltas), matching
27
+ * `bun test`'s `[12.34ms]` / `[1.50s]` convention: milliseconds below a second,
28
+ * seconds above.
29
+ */
30
+ export function formatDuration(ns: number): string {
31
+ const ms = ns / 1e6
32
+ return ms < 1000 ? `${ms.toFixed(2)}ms` : `${(ms / 1000).toFixed(2)}s`
33
+ }
34
+
35
+ /**
36
+ * One per-test line: ` a11y (pass) Button/default [light] [12.34ms]`. The
37
+ * `(pass)`/`(fail)`/`(record)` tag is fixed-text so a downstream summarizer can
38
+ * count outcomes without parsing colour or glyphs.
39
+ */
40
+ export function testLine(
41
+ phase: string,
42
+ name: string,
43
+ status: TestStatus,
44
+ durationNs: number,
45
+ ): string {
46
+ return ` ${phase.padEnd(PHASE_WIDTH)} ${STATUS_TAG[status]} ${name} [${formatDuration(durationNs)}]`
47
+ }
48
+
49
+ /** Per-phase pass/fail/recorded counts feeding the summary block. */
50
+ export interface PhaseTally {
51
+ pass: number
52
+ fail: number
53
+ record: number
54
+ }
55
+
56
+ export function emptyTally(): PhaseTally {
57
+ return { pass: 0, fail: 0, record: 0 }
58
+ }
59
+
60
+ /** Total tests a tally represents (recorded baselines count as run, not failed). */
61
+ function tallyTotal(t: PhaseTally): number {
62
+ return t.pass + t.fail + t.record
63
+ }
64
+
65
+ /**
66
+ * The closing summary, in `bun test`'s shape: a per-phase line, then the rolled-up
67
+ * `N pass` / `N fail`, then a `Ran …` line carrying the total wall-clock (which is
68
+ * less than the sum of per-test times when the phases run concurrently) and the
69
+ * concurrency the run used.
70
+ *
71
+ * Returns the lines without the trailing canonical `✓ checks passed` verdict —
72
+ * `check.ts` still prints that, so the overall pass/fail signal (and its exit
73
+ * code) is unchanged.
74
+ */
75
+ export function summaryLines(
76
+ phases: { phase: string; tally: PhaseTally }[],
77
+ totalNs: number,
78
+ concurrency: number,
79
+ ): string[] {
80
+ const out: string[] = ['']
81
+ let pass = 0
82
+ let fail = 0
83
+ let record = 0
84
+ let total = 0
85
+ for (const { phase, tally } of phases) {
86
+ pass += tally.pass
87
+ fail += tally.fail
88
+ record += tally.record
89
+ total += tallyTotal(tally)
90
+ const parts = [`${tally.pass} pass`, `${tally.fail} fail`]
91
+ if (tally.record) parts.push(`${tally.record} recorded`)
92
+ out.push(` ${phase.padEnd(PHASE_WIDTH)} ${parts.join(' ')}`)
93
+ }
94
+ out.push('')
95
+ out.push(` ${pass} pass`)
96
+ out.push(` ${fail} fail`)
97
+ if (record) out.push(` ${record} recorded`)
98
+ out.push(
99
+ ` Ran ${total} check${total === 1 ? '' : 's'} ` +
100
+ `[${formatDuration(totalNs)}] (concurrency ${concurrency})`,
101
+ )
102
+ return out
103
+ }
@@ -12,6 +12,12 @@ import type {
12
12
  RenderDriver,
13
13
  } from '../index'
14
14
  import { startDisplayCase } from '../server/server'
15
+ import {
16
+ emptyTally,
17
+ type PhaseTally,
18
+ summaryLines,
19
+ testLine,
20
+ } from './check-reporter'
15
21
  import { checkSsr } from './ssr-check'
16
22
  import { checkStructure } from './structure-check'
17
23
  import { checkTokens } from './tokens-check'
@@ -46,9 +52,41 @@ export interface CheckOptions {
46
52
  /** Restrict the render phases to components whose import closure touches a
47
53
  * file changed since this git ref (CLI `--changed[=ref]`). */
48
54
  changedRef?: string
55
+ /** How many variants the render phases (a11y/visual) scan concurrently.
56
+ * CLI `--concurrency=N`; defaults to {@link DEFAULT_CONCURRENCY}. */
57
+ concurrency?: number
49
58
  port?: number
50
59
  }
51
60
 
61
+ /** Default render-phase concurrency — modest, so a stock laptop isn't swamped by
62
+ * parallel headless pages while still cutting wall-clock well below serial. */
63
+ const DEFAULT_CONCURRENCY = 4
64
+
65
+ /**
66
+ * Run `worker` over `items` with at most `limit` in flight at once. Drives the
67
+ * a11y/visual phases: the browser work is I/O-bound, so concurrent pages overlap
68
+ * almost entirely. JS stays single-threaded, so the shared counters/arrays the
69
+ * workers mutate need no locking — only one worker runs between any two awaits.
70
+ */
71
+ async function mapPool<T>(
72
+ items: T[],
73
+ limit: number,
74
+ worker: (item: T) => Promise<void>,
75
+ ): Promise<void> {
76
+ let next = 0
77
+ const lanes = Array.from(
78
+ { length: Math.max(1, Math.min(limit, items.length)) },
79
+ async () => {
80
+ while (true) {
81
+ const i = next++
82
+ if (i >= items.length) return
83
+ await worker(items[i])
84
+ }
85
+ },
86
+ )
87
+ await Promise.all(lanes)
88
+ }
89
+
52
90
  interface Target {
53
91
  componentId: string
54
92
  caseId: string
@@ -359,43 +397,65 @@ export async function runChecks(
359
397
 
360
398
  const driver = await resolveDriver(config)
361
399
  const diff = opts.visual ? await resolveDiff(config) : null
400
+ const requested =
401
+ opts.concurrency ?? config.check?.concurrency ?? DEFAULT_CONCURRENCY
402
+ const concurrency =
403
+ Number.isFinite(requested) && requested > 0
404
+ ? Math.floor(requested)
405
+ : DEFAULT_CONCURRENCY
362
406
 
363
407
  let a11yViolations = 0
364
408
  let visualChanges = 0
365
- let recorded = 0
366
409
  const a11yReport: A11yReportEntry[] = []
367
-
368
- try {
369
- for (const t of targets) {
370
- const ctx: CaseContext = {
371
- componentId: t.componentId,
372
- caseId: t.caseId,
373
- theme: t.theme,
374
- width: VIEWPORT_WIDTH,
375
- }
376
- const page = await driver.open(t.renderUrl, ctx)
377
-
410
+ const a11yTally = emptyTally()
411
+ const visualTally = emptyTally()
412
+ const rel = (p: string) =>
413
+ p.startsWith(`${pkgDir}/`) ? p.slice(pkgDir.length + 1) : p
414
+
415
+ // Each variant is reported as a test — `(pass)`/`(fail)`/`(record)` with its own
416
+ // timing, in `bun test`'s shape — so a CI log can be grepped and summarized. The
417
+ // a11y and visual phases run together on one shared page per variant; the whole
418
+ // set is scanned with bounded concurrency (`mapPool`) since the work is
419
+ // browser-I/O-bound. A variant's lines are buffered and flushed as one block so
420
+ // concurrent variants never interleave mid-test.
421
+ async function scan(t: Target): Promise<void> {
422
+ const ctx: CaseContext = {
423
+ componentId: t.componentId,
424
+ caseId: t.caseId,
425
+ theme: t.theme,
426
+ width: VIEWPORT_WIDTH,
427
+ }
428
+ const name = `${t.componentId}/${t.caseId} [${t.theme}]`
429
+ const out: string[] = []
430
+ const page = await driver.open(t.renderUrl, ctx)
431
+ try {
378
432
  if (opts.a11y && a11yThemes.includes(t.theme)) {
433
+ const t0 = Bun.nanoseconds()
379
434
  const violations = await page.audit({ exclude: a11yExclude })
435
+ const dur = Bun.nanoseconds() - t0
380
436
  if (violations.length) {
437
+ a11yTally.fail++
381
438
  a11yReport.push({
382
439
  component: t.componentId,
383
440
  case: t.caseId,
384
441
  theme: t.theme,
385
442
  violations,
386
443
  })
387
- }
388
- for (const v of violations) {
389
- a11yViolations++
390
- const sev = v.impact ? `${v.impact} ` : ''
391
- console.error(
392
- ` a11y ${t.componentId}/${t.caseId} [${t.theme}] ${sev}${v.id}: ${v.help} (${v.nodes} node(s))`,
393
- )
394
- for (const line of a11yDetailLines(v)) console.error(line)
444
+ out.push(testLine('a11y', name, 'fail', dur))
445
+ for (const v of violations) {
446
+ a11yViolations++
447
+ const sev = v.impact ? `${v.impact} ` : ''
448
+ out.push(` ${sev}${v.id}: ${v.help} (${v.nodes} node(s))`)
449
+ for (const line of a11yDetailLines(v)) out.push(line)
450
+ }
451
+ } else {
452
+ a11yTally.pass++
453
+ out.push(testLine('a11y', name, 'pass', dur))
395
454
  }
396
455
  }
397
456
 
398
457
  if (opts.visual && diff) {
458
+ const t0 = Bun.nanoseconds()
399
459
  const shot = await page.screenshot()
400
460
  const file = join(
401
461
  baselines,
@@ -405,36 +465,52 @@ export async function runChecks(
405
465
  if (opts.update || !(await Bun.file(file).exists())) {
406
466
  await mkdir(dirname(file), { recursive: true })
407
467
  await Bun.write(file, shot)
408
- recorded++
468
+ visualTally.record++
469
+ out.push(testLine('visual', name, 'record', Bun.nanoseconds() - t0))
409
470
  } else {
410
471
  const baseline = new Uint8Array(await Bun.file(file).arrayBuffer())
411
472
  const res = await diff(
412
473
  { baseline, actual: shot },
413
474
  { ...ctx, baselinePath: file },
414
475
  )
476
+ const dur = Bun.nanoseconds() - t0
415
477
  if (res.changed) {
416
478
  visualChanges++
479
+ visualTally.fail++
480
+ let where = ''
417
481
  if (res.diffImage) {
418
- await Bun.write(
419
- file.replace(/\.png$/, '.diff.png'),
420
- res.diffImage,
421
- )
482
+ const diffPath = file.replace(/\.png$/, '.diff.png')
483
+ await Bun.write(diffPath, res.diffImage)
484
+ where = ` → ${rel(diffPath)}`
422
485
  }
423
- console.error(
424
- ` visual ${t.componentId}/${t.caseId} [${t.theme}] differs from baseline`,
425
- )
486
+ out.push(testLine('visual', name, 'fail', dur))
487
+ out.push(` differs from baseline${where}`)
488
+ } else {
489
+ visualTally.pass++
490
+ out.push(testLine('visual', name, 'pass', dur))
426
491
  }
427
492
  }
428
493
  }
429
-
494
+ } finally {
430
495
  await page.dispose()
431
496
  }
497
+ if (out.length) console.log(out.join('\n'))
498
+ }
499
+
500
+ const startedAt = Bun.nanoseconds()
501
+ try {
502
+ await mapPool(targets, concurrency, scan)
432
503
  } finally {
433
504
  await driver.close()
434
505
  server.stop(true)
435
506
  }
507
+ const totalNs = Bun.nanoseconds() - startedAt
436
508
 
437
- if (opts.visual && recorded) console.log(` recorded ${recorded} baseline(s)`)
509
+ const phases: { phase: string; tally: PhaseTally }[] = []
510
+ if (opts.a11y) phases.push({ phase: 'a11y', tally: a11yTally })
511
+ if (opts.visual) phases.push({ phase: 'visual', tally: visualTally })
512
+ for (const line of summaryLines(phases, totalNs, concurrency))
513
+ console.log(line)
438
514
 
439
515
  // Persist the full run (every failing variant, with per-node detail) so an
440
516
  // agent or human can read the exact failing colours/elements later without
@@ -446,15 +522,19 @@ export async function runChecks(
446
522
  await Bun.write(
447
523
  reportPath,
448
524
  `${JSON.stringify(
449
- { scannedAt: Date.now(), total: a11yViolations, results: a11yReport },
525
+ {
526
+ scannedAt: Date.now(),
527
+ durationMs: Math.round(totalNs / 1e6),
528
+ total: a11yViolations,
529
+ results: a11yReport,
530
+ },
450
531
  null,
451
532
  2,
452
533
  )}\n`,
453
534
  )
454
- const rel = reportPath.startsWith(`${pkgDir}/`)
455
- ? reportPath.slice(pkgDir.length + 1)
456
- : reportPath
457
- console.log(` a11y detail → ${rel}${a11yViolations ? '' : ' (clean run)'}`)
535
+ console.log(
536
+ ` a11y detail → ${rel(reportPath)}${a11yViolations ? '' : ' (clean run)'}`,
537
+ )
458
538
  }
459
539
 
460
540
  const ok =
package/src/cli.ts CHANGED
@@ -21,7 +21,7 @@ if (typeof globalThis.Bun === 'undefined') {
21
21
  *
22
22
  * display-case <pkgDir> [--port=N] start the dev server
23
23
  * display-case <pkgDir> --print-manifest print the manifest JSON and exit
24
- * display-case check <pkgDir> [--a11y] [--visual] [--tokens] [--structure] [--ssr] [--update] [--strict] [--only=ids] [--changed[=ref]] [--port=N]
24
+ * display-case check <pkgDir> [--a11y] [--visual] [--tokens] [--structure] [--ssr] [--update] [--strict] [--only=ids] [--changed[=ref]] [--concurrency=N] [--port=N]
25
25
  * display-case init <pkgDir> [--agent=claude] [--with-visual] [--dry-run] [--json]
26
26
  * display-case uninstall <pkgDir> [--agent=claude] [--dry-run] [--json]
27
27
  *
@@ -182,6 +182,10 @@ if (argv[0] === 'init' || argv[0] === 'uninstall') {
182
182
  process.env.DISPLAY_CASE_BASE_REF ??
183
183
  'origin/main')
184
184
  : undefined,
185
+ concurrency: (() => {
186
+ const n = Number.parseInt(option('concurrency') ?? '', 10)
187
+ return Number.isInteger(n) && n > 0 ? n : undefined
188
+ })(),
185
189
  port,
186
190
  })
187
191
  process.exit(ok ? 0 : 1)
package/src/index.ts CHANGED
@@ -411,6 +411,14 @@ export interface CheckConfig {
411
411
  * still be invoked explicitly by naming its flag.
412
412
  */
413
413
  defaultPhases?: Partial<Record<CheckPhase, boolean>>
414
+ /**
415
+ * How many variants the render phases (a11y/visual) scan concurrently. The
416
+ * built-in Playwright driver opens one page per variant from a shared browser
417
+ * context, so concurrent pages overlap the browser-bound work. Default 4; CLI
418
+ * `--concurrency=N` overrides. A custom `providers.driver` MUST tolerate
419
+ * concurrent `open()` calls for values above 1 (set this to `1` if it can't).
420
+ */
421
+ concurrency?: number
414
422
  /** Structure-phase rule configuration; each rule is on (at its default severity) unless overridden. */
415
423
  structure?: {
416
424
  /** Treat every structure warning as an error for the run (CI strict mode). */