@fusion.dev/helix-cli 0.191.0 → 0.193.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-cli",
3
3
  "description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
4
- "version": "0.191.0",
4
+ "version": "0.193.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -41,11 +41,11 @@
41
41
  "visual-regression"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@fusion.dev/helix-cli-darwin-arm64": "0.191.0",
45
- "@fusion.dev/helix-cli-darwin-x64": "0.191.0",
46
- "@fusion.dev/helix-cli-linux-arm64": "0.191.0",
47
- "@fusion.dev/helix-cli-linux-x64": "0.191.0",
48
- "@fusion.dev/helix-cli-win32-x64": "0.191.0"
44
+ "@fusion.dev/helix-cli-darwin-arm64": "0.193.0",
45
+ "@fusion.dev/helix-cli-darwin-x64": "0.193.0",
46
+ "@fusion.dev/helix-cli-linux-arm64": "0.193.0",
47
+ "@fusion.dev/helix-cli-linux-x64": "0.193.0",
48
+ "@fusion.dev/helix-cli-win32-x64": "0.193.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "playwright": ">=1.50"
package/visual/README.md CHANGED
@@ -116,6 +116,27 @@ export default defineConfig({
116
116
  one color, as a highlighter that failed leaves it. It reads the page after
117
117
  the first shot and needs no baseline. `selector` names your code blocks and
118
118
  `minimum` the fewest characters a block holds before it is judged.
119
+ - **`checks.clipped`**: fails a run on a box under `overflow: hidden` that cuts
120
+ off its own text, where no `text-overflow` or line clamp says it means to.
121
+ Only words that cross the edge count, so a carousel's slides out of view do
122
+ not.
123
+ - **`checks.overlap`**: fails a run on two boxes of text or controls that draw
124
+ over each other in the flow. A box positioned absolutely, fixed or sticky,
125
+ given a `z-index`, transformed, floated or in the top layer is placed on
126
+ purpose, and leaves the pair alone.
127
+ - **`checks.contrast`**: fails a run on an ink below WCAG 2.1 AA against the
128
+ ground it is painted on, in each color scheme the shots picture. The ground
129
+ is composited through every translucent layer, and a ground that is a
130
+ picture or a video is not judged. Text hidden from the accessibility tree is
131
+ a glyph for the eye, and answers to the 3:1 of a graphic. `rules` names the colors of lines that
132
+ separate, which answer to no target.
133
+ - Each check takes `ignore`, selectors it leaves alone with everything inside
134
+ them, and an exception's `checks` excuses one entry from the checks it names.
135
+ - **`manifest`**: custom elements manifests, relative to the config. A shot
136
+ that differs is told region by region: the element a reader would name that
137
+ covers the region, and the file a manifest says defines it. A baseline
138
+ carries its elements' boxes inside its PNG once a run writes it, and from
139
+ then on the run also says how far each element moved.
119
140
  - **`diff`**: how a shot is compared, for the whole config or for one source.
120
141
  `mode: 'pixel'` counts the pixels past `threshold`, 0.1 unless you set it,
121
142
  and fails above `pixels`, 0 unless you set it. `mode: 'perceptual'` scores
@@ -126,6 +147,8 @@ export default defineConfig({
126
147
  shot differ by that many, and the run fails once the shot stops differing, so
127
148
  an exception that is no longer needed is removed. `differs: true` tolerates a
128
149
  known difference between a pair's two sides, and fails once they match.
150
+ `checks` excuses the entry from the page checks it names, and fails once a
151
+ named check finds nothing there.
129
152
  - **`inspect`**: `run` is called with the Playwright page after each entry's
130
153
  first shot, and `report` once at the end with everything `run` returned.
131
154
  `report` returns `{ failed: true }` to fail the run.
@@ -0,0 +1,800 @@
1
+ /**
2
+ * The page checks that read the page a shot pictured, and need no baseline.
3
+ *
4
+ * **One evaluation with its helpers inside it**, because a function handed to
5
+ * the page carries nothing from this module: the walk through shadow roots,
6
+ * the parent in the flat tree, and the words for an element are written once
7
+ * here and shared by every check that runs.
8
+ *
9
+ * Each finding is `{ check, text }`, so the binary can hold one to an
10
+ * exception that names its check.
11
+ */
12
+
13
+ /**
14
+ * Reads the page for the checks `options` turns on: `clipped`, `overlap` and
15
+ * `contrast`, each with its own settings, or absent.
16
+ */
17
+ export const readPage = (page, options) =>
18
+ page.evaluate((options) => {
19
+ const findings = []
20
+
21
+ /* **Every element, shadow roots included**, because the boxes a component
22
+ lays out for itself are inside one, and the light tree alone holds only
23
+ what its user wrote. */
24
+ const everything = () => {
25
+ const found = []
26
+ const visit = (root) => {
27
+ for (const element of root.querySelectorAll('*')) {
28
+ found.push(element)
29
+ if (element.shadowRoot) {
30
+ visit(element.shadowRoot)
31
+ }
32
+ }
33
+ }
34
+ visit(document)
35
+ return found
36
+ }
37
+
38
+ /* **Up the flat tree, not the DOM tree.** Slotted content is laid out and
39
+ painted where its slot sits, so its parent is the slot. */
40
+ const up = (node) => {
41
+ if (node.assignedSlot) {
42
+ return node.assignedSlot
43
+ }
44
+ if (node.parentElement) {
45
+ return node.parentElement
46
+ }
47
+ const above = node.getRootNode()
48
+ return above instanceof ShadowRoot ? above.host : null
49
+ }
50
+
51
+ /* Down the flat tree: a slot's assigned nodes, a host's shadow root, or a
52
+ node's own children. */
53
+ const down = (node) => {
54
+ if (node instanceof HTMLSlotElement) {
55
+ const assigned = node.assignedNodes({ flatten: true })
56
+ return assigned.length > 0 ? assigned : [...node.childNodes]
57
+ }
58
+ if (node.shadowRoot) {
59
+ return [...node.shadowRoot.childNodes]
60
+ }
61
+ return [...node.childNodes]
62
+ }
63
+
64
+ const textsUnder = (node) => {
65
+ const found = []
66
+ const visit = (one) => {
67
+ for (const child of down(one)) {
68
+ if (child.nodeType === Node.TEXT_NODE) {
69
+ if (child.textContent.trim() !== '') {
70
+ found.push(child)
71
+ }
72
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
73
+ visit(child)
74
+ }
75
+ }
76
+ }
77
+ visit(node)
78
+ return found
79
+ }
80
+
81
+ const within = (node, ancestor) => {
82
+ for (let at = node; at; at = up(at)) {
83
+ if (at === ancestor) {
84
+ return true
85
+ }
86
+ }
87
+ return false
88
+ }
89
+
90
+ const ignored = (element, selectors) =>
91
+ selectors.length > 0 &&
92
+ (() => {
93
+ for (let at = element; at; at = up(at)) {
94
+ if (at.matches && selectors.some((selector) => at.matches(selector))) {
95
+ return true
96
+ }
97
+ }
98
+ return false
99
+ })()
100
+
101
+ /* **A box of one pixel is a label for a screen reader**, the
102
+ `clip-path: inset(50%)` pattern, and nobody is asked to read it. */
103
+ const shown = (element) => {
104
+ const box = element.getBoundingClientRect()
105
+ return (
106
+ box.width > 1 &&
107
+ box.height > 1 &&
108
+ element.checkVisibility({ opacityProperty: true, visibilityProperty: true })
109
+ )
110
+ }
111
+
112
+ /* The words for an element in a finding: its tag, and the part it plays
113
+ inside the component that holds it. */
114
+ const describe = (element) => {
115
+ const holder = element.getRootNode()
116
+ const own =
117
+ element.part?.length > 0
118
+ ? `::part(${[...element.part].join(' ')})`
119
+ : element.id
120
+ ? `#${element.id}`
121
+ : element.classList.length > 0
122
+ ? `.${element.classList[0]}`
123
+ : ''
124
+ const self = `${element.localName}${own}`
125
+ return holder instanceof ShadowRoot ? `${holder.host.localName} ${self}` : self
126
+ }
127
+
128
+ const sample = (text) => {
129
+ const one = text.trim().replace(/\s+/g, ' ')
130
+ return one.length > 40 ? `${one.slice(0, 40)}…` : one
131
+ }
132
+
133
+ const rectsOf = (text) => {
134
+ const range = document.createRange()
135
+ range.selectNodeContents(text)
136
+ return [...range.getClientRects()].filter((rect) => rect.width >= 1 && rect.height >= 1)
137
+ }
138
+
139
+ /* **Clipped text: a box that cuts its own words off.** A box under
140
+ `overflow: hidden` or `clip` whose text runs past its edge, unless the
141
+ author said so with `text-overflow` or a line clamp. Only words that
142
+ cross the edge count: words wholly outside the box are scrolled away,
143
+ as a carousel's other slides are, and nobody is shown half of them. A
144
+ word is reported once, against the innermost box that cuts it. */
145
+ if (options.clipped) {
146
+ const { ignore } = options.clipped
147
+ const cut = new Map()
148
+ for (const element of everything()) {
149
+ if (ignored(element, ignore) || !shown(element)) {
150
+ continue
151
+ }
152
+ const style = getComputedStyle(element)
153
+ const clipsX = ['hidden', 'clip'].includes(style.overflowX)
154
+ const clipsY = ['hidden', 'clip'].includes(style.overflowY)
155
+ const saidX = style.textOverflow !== 'clip'
156
+ const saidY = style.webkitLineClamp !== '' && style.webkitLineClamp !== 'none'
157
+ const overX = clipsX && !saidX && element.scrollWidth > element.clientWidth + 1
158
+ const overY = clipsY && !saidY && element.scrollHeight > element.clientHeight + 1
159
+ if (!overX && !overY) {
160
+ continue
161
+ }
162
+ const box = element.getBoundingClientRect()
163
+ const left = box.left + element.clientLeft
164
+ const top = box.top + element.clientTop
165
+ const right = left + element.clientWidth
166
+ const bottom = top + element.clientHeight
167
+ for (const text of textsUnder(element)) {
168
+ if (ignored(text.parentElement, ignore) || !shown(text.parentElement)) {
169
+ continue
170
+ }
171
+ /* Words inside a scrolling box below this one are scrolled content,
172
+ and a line half in view at a scroller's edge is how scrolling
173
+ looks. */
174
+ let scrolls = false
175
+ for (let at = text.parentElement; at && at !== element; at = up(at)) {
176
+ if (at.nodeType === Node.ELEMENT_NODE) {
177
+ const { overflowX, overflowY } = getComputedStyle(at)
178
+ if ([overflowX, overflowY].some((one) => one === 'auto' || one === 'scroll')) {
179
+ scrolls = true
180
+ break
181
+ }
182
+ }
183
+ }
184
+ if (scrolls) {
185
+ continue
186
+ }
187
+ for (const rect of rectsOf(text)) {
188
+ const crosses =
189
+ rect.right > left && rect.left < right && rect.bottom > top && rect.top < bottom
190
+ if (!crosses) {
191
+ continue
192
+ }
193
+ const past = [
194
+ ['right', overX ? rect.right - right : 0],
195
+ ['left', overX ? left - rect.left : 0],
196
+ ['bottom', overY ? rect.bottom - bottom : 0],
197
+ ['top', overY ? top - rect.top : 0],
198
+ ].sort((a, b) => b[1] - a[1])[0]
199
+ if (past[1] <= 1) {
200
+ continue
201
+ }
202
+ const before = cut.get(text)
203
+ if (before === undefined || within(element, before.element)) {
204
+ cut.set(text, { edge: past[0], element, pixels: Math.round(past[1]) })
205
+ }
206
+ break
207
+ }
208
+ }
209
+ }
210
+ const said = new Set()
211
+ for (const [text, { edge, element, pixels }] of cut) {
212
+ const line = `${describe(element)} cuts off its text ${pixels} px past its ${edge} edge: "${sample(text.textContent)}"`
213
+ if (!said.has(line)) {
214
+ said.add(line)
215
+ findings.push({ check: 'clipped', text: line })
216
+ }
217
+ }
218
+ }
219
+
220
+ /* **Overlap: two boxes that draw over each other with nothing saying
221
+ they should.** Text is judged by its words' own boxes and a control by
222
+ its border box. A pair where one holds the other is a layout. Either
223
+ one being placed on purpose is a stacking reason, and the pair is left
224
+ alone: positioned absolutely, fixed or sticky, given a `z-index`,
225
+ transformed, floated or in the top layer, itself or any box between it
226
+ and what holds both, or put by a grid in the cell the other is in. What
227
+ is left is two boxes in the flow that collide, which is a word running
228
+ into its neighbor. */
229
+ if (options.overlap) {
230
+ const { ignore } = options.overlap
231
+ const CONTROL =
232
+ 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"], [role="slider"], [tabindex]:not([tabindex="-1"])'
233
+ /* **What is in view of a box, not all of it.** A line scrolled out of
234
+ a list, or past a box that clips, draws nothing where its rectangle
235
+ says, so each rectangle is cut to the client box of every ancestor
236
+ that does not let its content overflow. */
237
+ const views = new Map()
238
+ const viewOf = (element) => {
239
+ if (!views.has(element)) {
240
+ let view = { bottom: Infinity, left: -Infinity, right: Infinity, top: -Infinity }
241
+ for (let at = up(element); at; at = up(at)) {
242
+ if (at.nodeType !== Node.ELEMENT_NODE) {
243
+ continue
244
+ }
245
+ const style = getComputedStyle(at)
246
+ const clipsX = style.overflowX !== 'visible'
247
+ const clipsY = style.overflowY !== 'visible'
248
+ if (clipsX || clipsY) {
249
+ const box = at.getBoundingClientRect()
250
+ const left = box.left + at.clientLeft
251
+ const top = box.top + at.clientTop
252
+ view = {
253
+ bottom: clipsY ? Math.min(view.bottom, top + at.clientHeight) : view.bottom,
254
+ left: clipsX ? Math.max(view.left, left) : view.left,
255
+ right: clipsX ? Math.min(view.right, left + at.clientWidth) : view.right,
256
+ top: clipsY ? Math.max(view.top, top) : view.top,
257
+ }
258
+ }
259
+ }
260
+ views.set(element, view)
261
+ }
262
+ return views.get(element)
263
+ }
264
+ const inView = (element, rects) => {
265
+ const view = viewOf(element)
266
+ return rects
267
+ .map((rect) => {
268
+ const left = Math.max(rect.left, view.left)
269
+ const right = Math.min(rect.right, view.right)
270
+ const top = Math.max(rect.top, view.top)
271
+ const bottom = Math.min(rect.bottom, view.bottom)
272
+ return { bottom, height: bottom - top, left, right, top, width: right - left }
273
+ })
274
+ .filter((rect) => rect.width > 0 && rect.height > 0)
275
+ }
276
+ const boxes = []
277
+ for (const element of everything()) {
278
+ if (ignored(element, ignore) || !shown(element)) {
279
+ continue
280
+ }
281
+ if (element.matches(CONTROL)) {
282
+ const rects = inView(element, [element.getBoundingClientRect()])
283
+ if (rects.length > 0) {
284
+ boxes.push({ element, rects })
285
+ }
286
+ continue
287
+ }
288
+ const own = [...element.childNodes].filter(
289
+ (child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '',
290
+ )
291
+ /* **The band a line's ink sits in, not its glyph box.** A glyph box
292
+ runs from the font's ascent to its descent, so the empty space under
293
+ a large figure's digits reached the label set close below it. A
294
+ fifth off the top and off the bottom leaves the capitals and the
295
+ x-height, which is where two lines that collide meet. */
296
+ const bands = own.flatMap(rectsOf).map((rect) => {
297
+ const trim = rect.height / 5
298
+ return {
299
+ bottom: rect.bottom - trim,
300
+ height: rect.height - 2 * trim,
301
+ left: rect.left,
302
+ right: rect.right,
303
+ top: rect.top + trim,
304
+ }
305
+ })
306
+ const rects = inView(element, bands)
307
+ if (rects.length > 0) {
308
+ boxes.push({ element, rects })
309
+ }
310
+ }
311
+ const placed = new Map()
312
+ /* A flex or grid item stacks by `z-index` with no `position`. */
313
+ const laidOut = (node) => {
314
+ const parent = up(node)
315
+ return (
316
+ parent?.nodeType === Node.ELEMENT_NODE &&
317
+ /flex|grid/.test(getComputedStyle(parent).display)
318
+ )
319
+ }
320
+ const onPurpose = (node) => {
321
+ if (!placed.has(node)) {
322
+ const style = getComputedStyle(node)
323
+ placed.set(
324
+ node,
325
+ ['absolute', 'fixed', 'sticky'].includes(style.position) ||
326
+ (style.zIndex !== 'auto' && (style.position !== 'static' || laidOut(node))) ||
327
+ style.transform !== 'none' ||
328
+ style.translate !== 'none' ||
329
+ style.float !== 'none' ||
330
+ node.matches(':popover-open, :modal'),
331
+ )
332
+ }
333
+ return placed.get(node)
334
+ }
335
+ const reason = (a, b) => {
336
+ const line = new Set()
337
+ for (let at = a; at; at = up(at)) {
338
+ line.add(at)
339
+ }
340
+ let shared = null
341
+ let fromB = null
342
+ for (let at = b; at; at = up(at)) {
343
+ if (line.has(at)) {
344
+ shared = at
345
+ break
346
+ }
347
+ if (at.nodeType === Node.ELEMENT_NODE && onPurpose(at)) {
348
+ return true
349
+ }
350
+ fromB = at
351
+ }
352
+ let fromA = null
353
+ for (let at = a; at && at !== shared; at = up(at)) {
354
+ if (at.nodeType === Node.ELEMENT_NODE && onPurpose(at)) {
355
+ return true
356
+ }
357
+ fromA = at
358
+ }
359
+ /* Two items a grid puts in one cell are stacked by its author, as a
360
+ range slider's two inputs are. */
361
+ if (
362
+ shared?.nodeType === Node.ELEMENT_NODE &&
363
+ fromA?.nodeType === Node.ELEMENT_NODE &&
364
+ fromB?.nodeType === Node.ELEMENT_NODE &&
365
+ /grid/.test(getComputedStyle(shared).display)
366
+ ) {
367
+ const one = getComputedStyle(fromA)
368
+ const other = getComputedStyle(fromB)
369
+ if (
370
+ one.gridRowStart !== 'auto' &&
371
+ one.gridColumnStart !== 'auto' &&
372
+ one.gridRowStart === other.gridRowStart &&
373
+ one.gridColumnStart === other.gridColumnStart
374
+ ) {
375
+ return true
376
+ }
377
+ }
378
+ return false
379
+ }
380
+ const said = new Set()
381
+ for (let i = 0; i < boxes.length; i += 1) {
382
+ for (let j = i + 1; j < boxes.length; j += 1) {
383
+ const a = boxes[i]
384
+ const b = boxes[j]
385
+ if (within(a.element, b.element) || within(b.element, a.element)) {
386
+ continue
387
+ }
388
+ let worst = null
389
+ for (const one of a.rects) {
390
+ for (const other of b.rects) {
391
+ const width = Math.min(one.right, other.right) - Math.max(one.left, other.left)
392
+ const height = Math.min(one.bottom, other.bottom) - Math.max(one.top, other.top)
393
+ /* More than a pixel each way: ink bands do not meet even in
394
+ lines set at a line height of 0.9, and a pixel is two borders
395
+ drawn as one, as a button group does. */
396
+ if (width > 2 && height > 1) {
397
+ if (worst === null || width * height > worst.width * worst.height) {
398
+ worst = { height, width }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ if (worst === null || reason(a.element, b.element)) {
404
+ continue
405
+ }
406
+ const words = (box) =>
407
+ sample(box.element.textContent) || box.element.getAttribute('aria-label') || ''
408
+ const line = `${describe(a.element)} and ${describe(b.element)} overlap by ${Math.round(worst.width)} by ${Math.round(worst.height)} px: "${words(a)}" and "${words(b)}"`
409
+ if (!said.has(line)) {
410
+ said.add(line)
411
+ findings.push({ check: 'overlap', text: line })
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ /* **Contrast: every ink against the ground it is really painted on.**
418
+ The instrument is the hard part, and each of these was once a component
419
+ reported broken when the probe was wrong: a transparent background
420
+ paints nothing, so the walk composites every partly-opaque layer up the
421
+ flat tree onto the first opaque one; a reading during a transition is
422
+ the previous value, which the settling before any shot already rules
423
+ out; and a ground that is not an ancestor, a sticky bar over a hero, is
424
+ found by reading the paint stack at the text's center, where a stranger
425
+ in between means nothing is claimed. */
426
+ if (options.contrast) {
427
+ const { component, ignore, large, largeBold, rules, scheme, target } = options.contrast
428
+
429
+ /* Read through a canvas, because a computed color comes back in the
430
+ notation it was written in, OKLCH included, and painting it is the
431
+ one way to get sRGB out of every notation. */
432
+ const swatch = document.createElement('canvas')
433
+ swatch.width = 1
434
+ swatch.height = 1
435
+ const brush = swatch.getContext('2d', { willReadFrequently: true })
436
+ const parse = (value) => {
437
+ if (!value || value === 'transparent' || value === 'none') {
438
+ return null
439
+ }
440
+ brush.fillStyle = '#010203'
441
+ brush.fillStyle = value
442
+ if (brush.fillStyle === '#010203' && !/^#010203$/i.test(value.trim())) {
443
+ return null
444
+ }
445
+ brush.clearRect(0, 0, 1, 1)
446
+ brush.fillRect(0, 0, 1, 1)
447
+ const [r, g, b, a] = brush.getImageData(0, 0, 1, 1).data
448
+ return { a: a / 255, b, g, r }
449
+ }
450
+ const over = (top, bottom) => ({
451
+ a: 1,
452
+ b: top.b * top.a + bottom.b * (1 - top.a),
453
+ g: top.g * top.a + bottom.g * (1 - top.a),
454
+ r: top.r * top.a + bottom.r * (1 - top.a),
455
+ })
456
+ const luminance = ({ b, g, r }) => {
457
+ const lin = (value) => {
458
+ const c = value / 255
459
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
460
+ }
461
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
462
+ }
463
+ const ratio = (ink, ground) => {
464
+ const a = luminance(ink)
465
+ const b = luminance(ground)
466
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
467
+ }
468
+ const hex = ({ b, g, r }) =>
469
+ `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`
470
+
471
+ /* What sits behind a picture, a video or a background image is not one
472
+ color, so no ratio against it means anything. Only media that covers
473
+ the text counts. */
474
+ const MEDIA = 'video, img, canvas, iframe, object, svg image'
475
+ const unbounded = (node, box) => {
476
+ if (getComputedStyle(node).backgroundImage !== 'none') {
477
+ return true
478
+ }
479
+ for (const painted of node.querySelectorAll?.(MEDIA) ?? []) {
480
+ const area = painted.getBoundingClientRect()
481
+ if (
482
+ area.left <= box.left &&
483
+ area.top <= box.top &&
484
+ area.right >= box.right &&
485
+ area.bottom >= box.bottom
486
+ ) {
487
+ return true
488
+ }
489
+ }
490
+ return false
491
+ }
492
+
493
+ /* Up the flat tree, every layer that is not fully transparent until one
494
+ is opaque, a modal's `::backdrop` included, which has no node. A
495
+ masked background is a glyph in the shape of its mask, and no
496
+ ground. */
497
+ const groundOf = (element, box) => {
498
+ const stack = []
499
+ let node = element
500
+ while (node) {
501
+ const style = getComputedStyle(node)
502
+ const masked = style.maskImage !== 'none' && style.maskImage !== ''
503
+ const layers = [
504
+ masked ? null : parse(style.backgroundColor),
505
+ parse(getComputedStyle(node, '::backdrop').backgroundColor),
506
+ ]
507
+ for (const layer of layers) {
508
+ if (!layer || layer.a === 0) {
509
+ continue
510
+ }
511
+ stack.push(layer)
512
+ if (layer.a === 1) {
513
+ let composited = stack.pop()
514
+ while (stack.length > 0) {
515
+ composited = over(stack.pop(), composited)
516
+ }
517
+ return { color: composited, on: node }
518
+ }
519
+ }
520
+ if (unbounded(node, box)) {
521
+ return null
522
+ }
523
+ node = up(node)
524
+ }
525
+ /* **What is left is the canvas**, which the browser paints behind a
526
+ page that gives its root no background: white, or dark under a dark
527
+ `color-scheme`. */
528
+ let composited = canvas
529
+ while (stack.length > 0) {
530
+ composited = over(stack.pop(), composited)
531
+ }
532
+ return { color: composited, on: document.documentElement }
533
+ }
534
+
535
+ /* A shadow root's `elementsFromPoint` reports the outer stack again, so
536
+ each element is entered once. */
537
+ const stackAt = (x, y, root = document, seen = new Set()) => {
538
+ const layers = []
539
+ for (const element of root.elementsFromPoint(x, y)) {
540
+ if (seen.has(element)) {
541
+ continue
542
+ }
543
+ seen.add(element)
544
+ if (element.shadowRoot) {
545
+ layers.push(...stackAt(x, y, element.shadowRoot, seen))
546
+ }
547
+ layers.push(element)
548
+ }
549
+ return layers
550
+ }
551
+ const contested = (element, ground, box) => {
552
+ const stack = stackAt(box.left + box.width / 2, box.top + box.height / 2)
553
+ const seen = stack.indexOf(element)
554
+ const floor = stack.indexOf(ground.on)
555
+ if (seen < 0 || floor < 0) {
556
+ return false
557
+ }
558
+ const line = new Set()
559
+ for (let node = element; node; node = up(node)) {
560
+ line.add(node)
561
+ }
562
+ for (const between of stack.slice(seen + 1, floor)) {
563
+ const paint = parse(getComputedStyle(between).backgroundColor)
564
+ if (!line.has(between) && paint && paint.a > 0) {
565
+ return true
566
+ }
567
+ }
568
+ return false
569
+ }
570
+
571
+ const canvas = (() => {
572
+ const probe = document.createElement('span')
573
+ probe.style.backgroundColor = 'Canvas'
574
+ document.documentElement.append(probe)
575
+ const color = parse(getComputedStyle(probe).backgroundColor) ?? {
576
+ a: 1,
577
+ b: 255,
578
+ g: 255,
579
+ r: 255,
580
+ }
581
+ probe.remove()
582
+ return { ...color, a: 1 }
583
+ })()
584
+
585
+ /* The colors that draw a rule and not an ink, read off the live page:
586
+ a line that separates is held to no target meant for a glyph. */
587
+ const probe = document.createElement('span')
588
+ probe.style.position = 'absolute'
589
+ document.body.append(probe)
590
+ const lines = new Set(
591
+ rules.map((rule) => {
592
+ probe.style.color = rule
593
+ return getComputedStyle(probe).color
594
+ }),
595
+ )
596
+ probe.remove()
597
+
598
+ /* An icon is ink too, held to the 3:1 of a graphic. Only a drawing of
599
+ one paint is read: a chart's root carries a paint its marks override,
600
+ and its figure would be of a color that is nowhere on screen. */
601
+ const iconInk = (element) => {
602
+ const paints = new Set()
603
+ for (const node of [element, ...element.querySelectorAll('*')]) {
604
+ const style = getComputedStyle(node)
605
+ for (const paint of [style.fill, style.stroke]) {
606
+ if (paint && paint !== 'none') {
607
+ paints.add(paint)
608
+ }
609
+ }
610
+ }
611
+ if (paints.size !== 1) {
612
+ return null
613
+ }
614
+ const [only] = [...paints]
615
+ return lines.has(only) ? null : parse(only)
616
+ }
617
+
618
+ /* A slotted label is painted by the box it lands in, through as many
619
+ slots as there are shadow roots between them. */
620
+ const painterOf = (element) => {
621
+ let node = [...element.childNodes].find(
622
+ (child) =>
623
+ child.nodeType === Node.TEXT_NODE &&
624
+ child.textContent.trim() !== '' &&
625
+ child.assignedSlot,
626
+ )
627
+ if (!node) {
628
+ return element
629
+ }
630
+ while (node.assignedSlot) {
631
+ node = node.assignedSlot
632
+ }
633
+ return node.parentElement ?? element
634
+ }
635
+
636
+ /* Scrolled to, so paint order can be read where the browser is looking,
637
+ and every scroll put back after, so the shots after this one picture
638
+ the page where it was. */
639
+ const scrolled = everything()
640
+ .filter((one) => one.scrollHeight > one.clientHeight || one.scrollWidth > one.clientWidth)
641
+ .map((one) => [one, one.scrollLeft, one.scrollTop])
642
+ const pageAt = [window.scrollX, window.scrollY]
643
+ const said = new Set()
644
+ for (const element of everything()) {
645
+ if (ignored(element, ignore)) {
646
+ continue
647
+ }
648
+ const text = [...element.childNodes].some(
649
+ (child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '',
650
+ )
651
+ if (!text && !(element instanceof SVGSVGElement)) {
652
+ continue
653
+ }
654
+ let box = element.getBoundingClientRect()
655
+ if (box.width <= 1 || box.height <= 1) {
656
+ continue
657
+ }
658
+ const painter = text ? painterOf(element) : element
659
+ if (painter !== element) {
660
+ const paints = painter.getBoundingClientRect()
661
+ if (paints.width <= 1 || paints.height <= 1) {
662
+ continue
663
+ }
664
+ }
665
+ const style = getComputedStyle(painter)
666
+ if (style.visibility === 'hidden' || style.opacity === '0') {
667
+ continue
668
+ }
669
+ const ink = text ? parse(style.color) : iconInk(element)
670
+ if (!ink) {
671
+ continue
672
+ }
673
+ if (box.top < 0 || box.bottom > window.innerHeight) {
674
+ element.scrollIntoView({ block: 'center' })
675
+ box = element.getBoundingClientRect()
676
+ }
677
+ if (box.top < 0 || box.bottom > window.innerHeight) {
678
+ continue
679
+ }
680
+ const ground = groundOf(painter, box)
681
+ if (!ground || contested(painter, ground, box)) {
682
+ continue
683
+ }
684
+ const size = Number.parseFloat(style.fontSize)
685
+ const bold = Number.parseInt(style.fontWeight, 10) >= 700
686
+ /* **Text hidden from the accessibility tree is a glyph drawn for the
687
+ eye**, an arrow beside a link's words, and a graphic answers to the
688
+ graphic's target. */
689
+ let drawn = false
690
+ for (let at = element; at; at = up(at)) {
691
+ if (at.nodeType === Node.ELEMENT_NODE && at.getAttribute('aria-hidden') === 'true') {
692
+ drawn = true
693
+ break
694
+ }
695
+ }
696
+ const wanted =
697
+ !text || drawn || size >= large || (bold && size >= largeBold) ? component : target
698
+ const flat = ink.a === 1 ? ink : over(ink, ground.color)
699
+ const measured = ratio(flat, ground.color)
700
+ if (measured < wanted) {
701
+ const line = `${describe(painter)} is ${hex(flat)} on ${hex(ground.color)} in ${scheme}, ${measured.toFixed(2)}:1 against ${wanted}:1: "${sample(element.textContent ?? '')}"`
702
+ if (!said.has(line)) {
703
+ said.add(line)
704
+ findings.push({ check: 'contrast', text: line })
705
+ }
706
+ }
707
+ }
708
+ for (const [one, left, top] of scrolled) {
709
+ one.scrollLeft = left
710
+ one.scrollTop = top
711
+ }
712
+ window.scrollTo(pageAt[0], pageAt[1])
713
+ }
714
+
715
+ return findings
716
+ }, options)
717
+
718
+ /**
719
+ * Where each element a reader would name sat when the page was pictured, in
720
+ * the picture's own pixels.
721
+ *
722
+ * **Named, because a change is told by what it happened to.** A custom
723
+ * element and each part it lays out, anything with an `id` or a
724
+ * `data-testid`, and the landmarks, headings, controls and pictures a page is
725
+ * read by. Each is keyed by the named elements above it and its place among
726
+ * its namesakes, so the same element is found again in another picture of the
727
+ * page, which is how a run says it moved.
728
+ */
729
+ export const boxesOf = (page) =>
730
+ page.evaluate(() => {
731
+ const LIMIT = 4000
732
+ const READ =
733
+ 'header, nav, main, aside, footer, section, form, h1, h2, h3, h4, h5, h6, a[href], button, input, select, textarea, img, svg, video, canvas, [role]'
734
+ const up = (node) => {
735
+ if (node.assignedSlot) {
736
+ return node.assignedSlot
737
+ }
738
+ if (node.parentElement) {
739
+ return node.parentElement
740
+ }
741
+ const above = node.getRootNode()
742
+ return above instanceof ShadowRoot ? above.host : null
743
+ }
744
+ const label = (element) => {
745
+ if (element.part?.length > 0) {
746
+ const holder = element.getRootNode()
747
+ return `${holder instanceof ShadowRoot ? '' : element.localName}::part(${[...element.part].join(' ')})`
748
+ }
749
+ if (element.id) {
750
+ return `${element.localName}#${element.id}`
751
+ }
752
+ const test = element.getAttribute('data-testid')
753
+ return test ? `${element.localName}[data-testid="${test}"]` : element.localName
754
+ }
755
+ const named = (element) =>
756
+ element.localName.includes('-') ||
757
+ element.part?.length > 0 ||
758
+ element.id !== '' ||
759
+ element.hasAttribute('data-testid') ||
760
+ element.matches(READ)
761
+ /* What holds an element in a key: the custom elements and the elements
762
+ with an `id` above it, which a reader names it by. */
763
+ const holds = (element) => element.localName.includes('-') || element.id !== ''
764
+ const found = []
765
+ const seen = new Map()
766
+ const visit = (root) => {
767
+ for (const element of root.querySelectorAll('*')) {
768
+ if (found.length >= LIMIT) {
769
+ return
770
+ }
771
+ if (named(element)) {
772
+ const box = element.getBoundingClientRect()
773
+ if (box.width > 0 && box.height > 0) {
774
+ const path = [label(element)]
775
+ for (let at = up(element); at; at = up(at)) {
776
+ if (at.nodeType === Node.ELEMENT_NODE && holds(at)) {
777
+ path.unshift(label(at))
778
+ }
779
+ }
780
+ const joined = path.join(' ')
781
+ const count = (seen.get(joined) ?? 0) + 1
782
+ seen.set(joined, count)
783
+ found.push({
784
+ height: Math.round(box.height),
785
+ key: count === 1 ? joined : `${joined} (${count})`,
786
+ tag: element.localName,
787
+ width: Math.round(box.width),
788
+ x: Math.round(box.left + window.scrollX),
789
+ y: Math.round(box.top + window.scrollY),
790
+ })
791
+ }
792
+ }
793
+ if (element.shadowRoot) {
794
+ visit(element.shadowRoot)
795
+ }
796
+ }
797
+ }
798
+ visit(document)
799
+ return found
800
+ })
package/visual/driver.mjs CHANGED
@@ -39,6 +39,7 @@ import { createServer } from 'node:http'
39
39
  import { extname, resolve, sep } from 'node:path'
40
40
  import { StringDecoder } from 'node:string_decoder'
41
41
  import { pathToFileURL } from 'node:url'
42
+ import { boxesOf, readPage } from './checks.mjs'
42
43
 
43
44
  /** The `id` of the harness `<style>` the binary writes into every page. */
44
45
  const STYLE_ID = 'helix-visual-harness'
@@ -376,6 +377,8 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
376
377
  let problems = []
377
378
  let findings = []
378
379
  let aria = null
380
+ /* The color schemes contrast has been read in. */
381
+ const read = new Set()
379
382
  lane.errors.length = 0
380
383
  lane.messages.length = 0
381
384
  try {
@@ -432,7 +435,10 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
432
435
  if (progress.abandoned) {
433
436
  return
434
437
  }
435
- say({ key: item.key, op: 'shot', png: png.toString('base64'), shot: shot.name })
438
+ /* Where the named elements sat in this picture, when the run asked:
439
+ written into a baseline, and read to say what a change happened to. */
440
+ const boxes = item.boxes ? await boxesOf(page) : null
441
+ say({ boxes, key: item.key, op: 'shot', png: png.toString('base64'), shot: shot.name })
436
442
  /* After the first picture, on the page that was pictured, so what the
437
443
  inspection reads cannot move a pixel of that picture. */
438
444
  if (index === 0 && item.inspect) {
@@ -443,10 +449,25 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
443
449
  })
444
450
  }
445
451
  /* The page checks and the accessibility snapshot, on the page that was
446
- pictured, after its first picture. */
452
+ pictured, after its first picture. Contrast reads again after the
453
+ first picture in each color scheme, because a palette has two halves
454
+ and only one is on screen at a time. */
447
455
  if (index === 0 && item.checks?.code) {
448
456
  progress.stage = 'checking its code blocks'
449
- findings = findings.concat(await codeBlocks(page, item.checks.code))
457
+ const blocks = await codeBlocks(page, item.checks.code)
458
+ findings = findings.concat(blocks.map((text) => ({ check: 'code', text })))
459
+ }
460
+ const contrast = item.checks?.contrast && !read.has(shot.scheme)
461
+ if ((index === 0 && (item.checks?.clipped || item.checks?.overlap)) || contrast) {
462
+ progress.stage = 'reading the page'
463
+ read.add(shot.scheme)
464
+ findings = findings.concat(
465
+ await readPage(page, {
466
+ clipped: index === 0 ? (item.checks.clipped ?? null) : null,
467
+ contrast: contrast ? { ...item.checks.contrast, scheme: shot.scheme } : null,
468
+ overlap: index === 0 ? (item.checks.overlap ?? null) : null,
469
+ }),
470
+ )
450
471
  }
451
472
  if (index === 0 && item.aria) {
452
473
  progress.stage = 'taking its accessibility snapshot'
package/visual/index.d.ts CHANGED
@@ -266,6 +266,11 @@ export interface Inspect<Result = unknown> {
266
266
 
267
267
  /** One exception to the comparison, with the reason it exists. */
268
268
  export interface Exception {
269
+ /**
270
+ * Page checks whose findings on this entry are excused, by name: `clipped`, `code`, `contrast` or
271
+ * `overlap`. Each fails the run once its check finds nothing on the entry.
272
+ */
273
+ checks?: Array<'clipped' | 'code' | 'contrast' | 'overlap'>
269
274
  /** A known difference between a pair's two sides, tolerated however large. It fails the run once the two match on two takes. */
270
275
  differs?: boolean
271
276
  /** An entry's id, `styled/aura`, or its name in every source, `aura`. */
@@ -299,6 +304,35 @@ export interface Config {
299
304
  * `minimum`, 20 when not given, for the fewest characters a block holds before it is judged.
300
305
  */
301
306
  code?: boolean | { minimum?: number; selector?: string }
307
+ /**
308
+ * A box under `overflow: hidden` or `clip` that cuts off its own text, where no `text-overflow` or line
309
+ * clamp says it means to. Only words that cross the box's edge count, so slides scrolled wholly out of
310
+ * view do not. `ignore` names elements the check leaves alone, with everything inside them.
311
+ */
312
+ clipped?: boolean | { ignore?: string[] }
313
+ /**
314
+ * Each ink against the ground it is painted on, in every color scheme the shots picture, against WCAG
315
+ * 2.1 AA: `target`, 4.5, for text, and `component`, 3, for icons, for text hidden from the accessibility
316
+ * tree, and for text from `large` pixels, 24, or from `largeBold` when bold, 18.66. A ground behind a
317
+ * picture, a video or a background image is not one color, and is not judged. `rules` are colors that
318
+ * draw a line that separates, held to no target.
319
+ */
320
+ contrast?:
321
+ | boolean
322
+ | {
323
+ component?: number
324
+ ignore?: string[]
325
+ large?: number
326
+ largeBold?: number
327
+ rules?: string[]
328
+ target?: number
329
+ }
330
+ /**
331
+ * Two boxes of text or controls that draw over each other in the flow. A box positioned absolutely,
332
+ * fixed or sticky, given a `z-index`, transformed, floated or in the top layer is placed on purpose, and
333
+ * leaves the pair alone.
334
+ */
335
+ overlap?: boolean | { ignore?: string[] }
302
336
  }
303
337
  /** How a shot is compared with its baseline, unless its source sets its own. */
304
338
  diff?: Diff
@@ -308,6 +342,11 @@ export interface Config {
308
342
  fingerprint?: string
309
343
  /** Checks of your own on each page, after its first picture. */
310
344
  inspect?: Inspect
345
+ /**
346
+ * Custom elements manifests, `custom-elements.json`, relative to the config. A change traced to a custom
347
+ * element names the file its manifest says defines it.
348
+ */
349
+ manifest?: string[]
311
350
  /**
312
351
  * Selectors whose elements are painted over in one fixed color in every picture and left out of every
313
352
  * accessibility snapshot, for what changes on its own and says nothing about the page, such as a version