@erclx/canon 4.5.0 → 4.6.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/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/driver.md +103 -0
- package/docs/agents/index.md +1 -0
- package/package.json +1 -1
- package/src/browser/engine.ts +50 -7
- package/src/cli.ts +4 -0
- package/src/commands/driver.ts +266 -0
- package/src/driver/drive.ts +184 -0
- package/src/driver/probes/details.ts +116 -0
- package/src/driver/probes/diagram.ts +260 -0
- package/src/driver/probes/focus.ts +121 -0
- package/src/driver/probes/viewport.ts +81 -0
- package/src/driver/steps.ts +266 -0
- package/src/inventory/walk.ts +6 -18
- package/tooling/claude/seeds/CLAUDE.md +2 -7
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core'
|
|
2
|
+
import type { Browser, Page } from 'playwright-core'
|
|
3
|
+
import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
|
|
4
|
+
import { probeDetails } from '@/driver/probes/details'
|
|
5
|
+
import {
|
|
6
|
+
probeDiagramGeometry,
|
|
7
|
+
probeDiagramStrokes,
|
|
8
|
+
} from '@/driver/probes/diagram'
|
|
9
|
+
import { probeFocus } from '@/driver/probes/focus'
|
|
10
|
+
import { describeViewport } from '@/driver/probes/viewport'
|
|
11
|
+
import type { Viewport } from '@/driver/probes/viewport'
|
|
12
|
+
import { runStep, stepProbes } from '@/driver/steps'
|
|
13
|
+
import type {
|
|
14
|
+
DriverPlan,
|
|
15
|
+
Finding,
|
|
16
|
+
PlacedFinding,
|
|
17
|
+
ProbeName,
|
|
18
|
+
} from '@/driver/steps'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Walks a page through a caller's interaction sequence and measures each state
|
|
22
|
+
* it reaches. Every browser reference the driver adds lives here, and
|
|
23
|
+
* `src/commands/driver.ts` reaches it through a dynamic import so no other
|
|
24
|
+
* command resolves the engine at startup.
|
|
25
|
+
*
|
|
26
|
+
* Like `@/demo/drive` and `@/inventory/walk`, and unlike `@/capture/render`,
|
|
27
|
+
* this module ships. A command whose whole purpose is measuring someone else's
|
|
28
|
+
* page cannot stay toolkit-only, and nothing here reaches into `@/capture/`,
|
|
29
|
+
* which `files` in `package.json` excludes from the published package, so the
|
|
30
|
+
* two surfaces move independently.
|
|
31
|
+
*
|
|
32
|
+
* What separates it from `canon capture` is the axis it adds. A render answers
|
|
33
|
+
* about a page as it loads, and every defect that exists only after a menu
|
|
34
|
+
* opens, an answer is chosen, or the page scrolls is invisible to one. Probes
|
|
35
|
+
* therefore run after a step rather than on arrival, and a run reaches the load
|
|
36
|
+
* state by opening with a `wait` step of its own. That matters most where
|
|
37
|
+
* capture does not run, since it is toolkit-only and this command is then the
|
|
38
|
+
* only thing measuring the page at all.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Each probe keyed by the name a caller writes in the run. */
|
|
42
|
+
const PROBES: Record<ProbeName, (page: Page) => Promise<Finding[]>> = {
|
|
43
|
+
focus: probeFocus,
|
|
44
|
+
details: probeDetails,
|
|
45
|
+
'diagram-geometry': probeDiagramGeometry,
|
|
46
|
+
'diagram-strokes': probeDiagramStrokes,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DriveOptions {
|
|
50
|
+
readonly url: string
|
|
51
|
+
readonly plan: DriverPlan
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One step measured at one viewport, so a sweep reads as passes rather than a total. */
|
|
55
|
+
export interface PassReading {
|
|
56
|
+
readonly viewport: string
|
|
57
|
+
readonly step: string
|
|
58
|
+
readonly probes: number
|
|
59
|
+
readonly findings: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DriverRefusal =
|
|
63
|
+
| 'browser-missing'
|
|
64
|
+
| 'server-unreachable'
|
|
65
|
+
| 'drive-failed'
|
|
66
|
+
|
|
67
|
+
export type DriverResult =
|
|
68
|
+
| {
|
|
69
|
+
readonly status: 'driven'
|
|
70
|
+
readonly findings: readonly PlacedFinding[]
|
|
71
|
+
readonly passes: readonly PassReading[]
|
|
72
|
+
readonly durationMs: number
|
|
73
|
+
}
|
|
74
|
+
| {
|
|
75
|
+
readonly status: 'failed'
|
|
76
|
+
readonly reason: DriverRefusal
|
|
77
|
+
readonly message: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function failed(reason: DriverRefusal, error: unknown): DriverResult {
|
|
81
|
+
return {
|
|
82
|
+
status: 'failed',
|
|
83
|
+
reason,
|
|
84
|
+
message: error instanceof Error ? error.message : String(error),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function drive(options: DriveOptions): Promise<DriverResult> {
|
|
89
|
+
const started = Date.now()
|
|
90
|
+
|
|
91
|
+
let browser: Browser
|
|
92
|
+
try {
|
|
93
|
+
browser = await chromium.launch()
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return failed(
|
|
96
|
+
isBrowserMissing(error) ? 'browser-missing' : 'drive-failed',
|
|
97
|
+
error,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const findings: PlacedFinding[] = []
|
|
102
|
+
const passes: PassReading[] = []
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
for (const viewport of options.plan.viewports) {
|
|
106
|
+
const pass = await driveViewport(browser, options, viewport)
|
|
107
|
+
findings.push(...pass.findings)
|
|
108
|
+
passes.push(...pass.passes)
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return failed(
|
|
112
|
+
isServerUnreachable(error) ? 'server-unreachable' : 'drive-failed',
|
|
113
|
+
error,
|
|
114
|
+
)
|
|
115
|
+
} finally {
|
|
116
|
+
// Dropped rather than propagated, because a close that fails beside a drive
|
|
117
|
+
// that already failed would replace the refusal the caller is about to
|
|
118
|
+
// receive with a reason about teardown.
|
|
119
|
+
await browser.close().catch(() => undefined)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
status: 'driven',
|
|
124
|
+
findings,
|
|
125
|
+
passes,
|
|
126
|
+
durationMs: Date.now() - started,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One full pass of the sequence at one viewport, in a context of its own.
|
|
132
|
+
*
|
|
133
|
+
* A fresh context per height rather than a resize of the last one, because a
|
|
134
|
+
* step already run has left the page in a state the next height would inherit,
|
|
135
|
+
* and a sweep exists to ask the same question of each rather than to ask a
|
|
136
|
+
* later one of a page the earlier heights already drove.
|
|
137
|
+
*/
|
|
138
|
+
async function driveViewport(
|
|
139
|
+
browser: Browser,
|
|
140
|
+
options: DriveOptions,
|
|
141
|
+
viewport: Viewport,
|
|
142
|
+
): Promise<{ findings: PlacedFinding[]; passes: PassReading[] }> {
|
|
143
|
+
const context = await browser.newContext({
|
|
144
|
+
viewport,
|
|
145
|
+
// Pointed the opposite way from `@/demo/drive`, which asks for the motion
|
|
146
|
+
// the interface was designed with. A measurement wants it suppressed, since
|
|
147
|
+
// geometry read mid-transition is a reading of neither state.
|
|
148
|
+
reducedMotion: 'reduce',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const findings: PlacedFinding[] = []
|
|
152
|
+
const passes: PassReading[] = []
|
|
153
|
+
const where = describeViewport(viewport)
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const page = await context.newPage()
|
|
157
|
+
await page.goto(options.url, { waitUntil: 'domcontentloaded' })
|
|
158
|
+
|
|
159
|
+
for (const step of options.plan.steps) {
|
|
160
|
+
await runStep(page, step)
|
|
161
|
+
|
|
162
|
+
const names = stepProbes(options.plan, step)
|
|
163
|
+
let count = 0
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const probe = PROBES[name]
|
|
166
|
+
for (const finding of await probe(page)) {
|
|
167
|
+
findings.push({ ...finding, step: step.name, viewport: where })
|
|
168
|
+
count += 1
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
passes.push({
|
|
173
|
+
viewport: where,
|
|
174
|
+
step: step.name,
|
|
175
|
+
probes: names.length,
|
|
176
|
+
findings: count,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
await context.close().catch(() => undefined)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { findings, passes }
|
|
184
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core'
|
|
2
|
+
import type { Finding } from '@/driver/steps'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Measures every `<details>` on the page twice, once shut and once open, and
|
|
6
|
+
* judges only the open reading.
|
|
7
|
+
*
|
|
8
|
+
* Two lessons meet here and they pull opposite ways. A closed `<details>` still
|
|
9
|
+
* reports a layout box and the box is nonsense: at 390 pixels wide a shut menu
|
|
10
|
+
* measures 13, so its rows read as 18-pixel tap targets hanging off the
|
|
11
|
+
* viewport, which is where 134 false findings came from in a single run.
|
|
12
|
+
* Filtering the closed ones out then opens a blind spot the same size, since
|
|
13
|
+
* the one real menu defect on that page was an open dropdown sitting 35 pixels
|
|
14
|
+
* off a phone screen and true only while open. Neither reading alone is enough,
|
|
15
|
+
* so both are taken and the closed one is carried as context rather than judged.
|
|
16
|
+
*
|
|
17
|
+
* Discovery is automatic rather than selector-driven. The real defect was
|
|
18
|
+
* missed because nobody thought to name that menu, and a probe that takes a
|
|
19
|
+
* selector reproduces that failure every time the author's attention is the
|
|
20
|
+
* thing at fault.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The smallest a control may be before a finger cannot reliably hit it. */
|
|
24
|
+
const MIN_TAP_PX = 24
|
|
25
|
+
|
|
26
|
+
/** Ignores a sub-pixel overhang, which is a rounding artifact rather than a defect. */
|
|
27
|
+
const OVERFLOW_TOLERANCE_PX = 1
|
|
28
|
+
|
|
29
|
+
export async function probeDetails(page: Page): Promise<Finding[]> {
|
|
30
|
+
const rows = await page.evaluate(
|
|
31
|
+
([minTap, tolerance]: [number, number]) => {
|
|
32
|
+
const describe = (element: Element): string => {
|
|
33
|
+
const tag = element.tagName.toLowerCase()
|
|
34
|
+
if (element.id) return `${tag}#${element.id}`
|
|
35
|
+
const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
|
|
36
|
+
return className ? `${tag}.${className}` : tag
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const box = (element: Element) => {
|
|
40
|
+
const rect = element.getBoundingClientRect()
|
|
41
|
+
return {
|
|
42
|
+
left: rect.left,
|
|
43
|
+
right: rect.right,
|
|
44
|
+
width: rect.width,
|
|
45
|
+
height: rect.height,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const found: {
|
|
50
|
+
selector: string
|
|
51
|
+
detail: string
|
|
52
|
+
measured: string
|
|
53
|
+
}[] = []
|
|
54
|
+
|
|
55
|
+
const width = document.documentElement.clientWidth
|
|
56
|
+
|
|
57
|
+
for (const element of Array.from(document.querySelectorAll('details'))) {
|
|
58
|
+
if (!(element instanceof HTMLDetailsElement)) continue
|
|
59
|
+
|
|
60
|
+
// Named for the state it was actually in rather than assumed shut. A
|
|
61
|
+
// step that clicked the menu open leaves this reading an open one, and
|
|
62
|
+
// calling it shut would report a number as evidence of the opposite
|
|
63
|
+
// state to the one it was taken in.
|
|
64
|
+
const wasOpen = element.open
|
|
65
|
+
const entry = box(element)
|
|
66
|
+
const entryState = wasOpen ? 'already open' : 'shut'
|
|
67
|
+
element.open = true
|
|
68
|
+
|
|
69
|
+
const rows = Array.from(
|
|
70
|
+
element.querySelectorAll('a[href], button, [role="menuitem"], li'),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// The panel is routinely out of flow, so the element's own box stops at
|
|
74
|
+
// the summary and says nothing about where the menu landed. Every part
|
|
75
|
+
// is measured and the furthest overhang is the finding.
|
|
76
|
+
let worst: { part: Element; off: number; left: number } | undefined
|
|
77
|
+
for (const part of [element, ...element.querySelectorAll('*')]) {
|
|
78
|
+
const partBox = box(part)
|
|
79
|
+
if (partBox.width === 0 && partBox.height === 0) continue
|
|
80
|
+
const off = Math.max(partBox.right - width, -partBox.left)
|
|
81
|
+
if (off <= tolerance) continue
|
|
82
|
+
if (worst && off <= worst.off) continue
|
|
83
|
+
worst = { part, off, left: partBox.left }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (worst) {
|
|
87
|
+
found.push({
|
|
88
|
+
selector: describe(worst.part),
|
|
89
|
+
detail: `sits ${Math.round(worst.off)}px outside the viewport once ${describe(element)} is open`,
|
|
90
|
+
measured: `open at left ${Math.round(worst.left)} in a ${width}px viewport, against an ${entryState} ${describe(element)} box of ${Math.round(entry.width)}x${Math.round(entry.height)} that measures nothing about where the panel lands`,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const item = box(row)
|
|
96
|
+
if (item.width === 0 && item.height === 0) continue
|
|
97
|
+
if (item.width >= minTap && item.height >= minTap) continue
|
|
98
|
+
found.push({
|
|
99
|
+
selector: `${describe(element)} ${describe(row)}`,
|
|
100
|
+
detail: `is ${Math.round(item.width)}x${Math.round(item.height)} once open, under the ${minTap}px minimum`,
|
|
101
|
+
measured: `read in the open state, since the ${entryState} box of ${Math.round(entry.width)}x${Math.round(entry.height)} reports every row inside it wrong`,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Restored because the probe runs between the caller's own steps, and a
|
|
106
|
+
// menu this left hanging open is a state no later step asked for.
|
|
107
|
+
element.open = wasOpen
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return found
|
|
111
|
+
},
|
|
112
|
+
[MIN_TAP_PX, OVERFLOW_TOLERANCE_PX] as [number, number],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return rows.map((row) => ({ probe: 'details' as const, ...row }))
|
|
116
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core'
|
|
2
|
+
import type { Finding } from '@/driver/steps'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads SVG label geometry off the rendered page, in two probes that differ by
|
|
6
|
+
* what they compare a label against.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is measured in the browser after `document.fonts.ready` and
|
|
9
|
+
* never computed from the markup, because a generated page is routinely
|
|
10
|
+
* authored against one font and rendered in another. A stylesheet restyling
|
|
11
|
+
* `figure svg text` to a hand face after the coordinates were picked against
|
|
12
|
+
* monospace moves every label, so a label that cleared a line at its authored
|
|
13
|
+
* width can overlap it once rendered and the markup still says it does not.
|
|
14
|
+
*
|
|
15
|
+
* `clientWidth` is not the reading. Comparing it to `scrollWidth` flags every
|
|
16
|
+
* label whose string is wider than its element box whether or not anything is
|
|
17
|
+
* actually cut, which is why both probes are geometry against other geometry.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Trims the slack a text rect carries above and below the glyphs. Vertical
|
|
22
|
+
* only: a text rect is loose top and bottom and tight to the glyphs left and
|
|
23
|
+
* right, and a one-pixel horizontal inset was enough to pass a label overrunning
|
|
24
|
+
* a panel border by three tenths of a unit. Insetting the vertical band alone
|
|
25
|
+
* stayed free of false positives across 41 strokes.
|
|
26
|
+
*/
|
|
27
|
+
const VERTICAL_INSET_PX = 2
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How far two labels overlap before it counts. An SVG text rect is the full
|
|
31
|
+
* font box rather than the ink, so two properly spaced lines touch by a pixel
|
|
32
|
+
* and a zero threshold reports every one of them.
|
|
33
|
+
*/
|
|
34
|
+
const COLLISION_PX = 2
|
|
35
|
+
|
|
36
|
+
/** Sampling interval along a stroke, in device pixels. */
|
|
37
|
+
const STROKE_SAMPLE_PX = 2
|
|
38
|
+
|
|
39
|
+
/** Bounds the sampling of one stroke, so a long path cannot stall the pass. */
|
|
40
|
+
const MAX_STROKE_SAMPLES = 600
|
|
41
|
+
|
|
42
|
+
export async function probeDiagramGeometry(page: Page): Promise<Finding[]> {
|
|
43
|
+
const rows = await page.evaluate(
|
|
44
|
+
async ([inset, collision]: [number, number]) => {
|
|
45
|
+
await document.fonts.ready
|
|
46
|
+
|
|
47
|
+
const describe = (element: Element): string => {
|
|
48
|
+
const owner = element.closest('svg')
|
|
49
|
+
const label = (element.textContent ?? '').trim().slice(0, 40)
|
|
50
|
+
const id = owner?.id ? `svg#${owner.id}` : 'svg'
|
|
51
|
+
return label ? `${id} text "${label}"` : `${id} text`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const insetRect = (element: Element) => {
|
|
55
|
+
const rect = element.getBoundingClientRect()
|
|
56
|
+
return {
|
|
57
|
+
left: rect.left,
|
|
58
|
+
right: rect.right,
|
|
59
|
+
top: rect.top + inset,
|
|
60
|
+
bottom: rect.bottom - inset,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const overlap = (
|
|
65
|
+
a: { left: number; right: number; top: number; bottom: number },
|
|
66
|
+
b: { left: number; right: number; top: number; bottom: number },
|
|
67
|
+
) => ({
|
|
68
|
+
x: Math.min(a.right, b.right) - Math.max(a.left, b.left),
|
|
69
|
+
y: Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const found: { selector: string; detail: string; measured: string }[] = []
|
|
73
|
+
|
|
74
|
+
for (const root of Array.from(document.querySelectorAll('svg'))) {
|
|
75
|
+
const painted = Array.from(root.querySelectorAll('*'))
|
|
76
|
+
const labels = Array.from(root.querySelectorAll('text'))
|
|
77
|
+
const frame = root.getBoundingClientRect()
|
|
78
|
+
|
|
79
|
+
for (const label of labels) {
|
|
80
|
+
const rect = insetRect(label)
|
|
81
|
+
if (rect.right <= rect.left || rect.bottom <= rect.top) continue
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
rect.left < frame.left - collision ||
|
|
85
|
+
rect.right > frame.right + collision ||
|
|
86
|
+
rect.top < frame.top - collision ||
|
|
87
|
+
rect.bottom > frame.bottom + collision
|
|
88
|
+
) {
|
|
89
|
+
found.push({
|
|
90
|
+
selector: describe(label),
|
|
91
|
+
detail: 'is painted outside its own frame',
|
|
92
|
+
measured: `label spans ${Math.round(rect.left)}..${Math.round(rect.right)} horizontally and ${Math.round(rect.top)}..${Math.round(rect.bottom)} vertically, against a frame of ${Math.round(frame.left)}..${Math.round(frame.right)} and ${Math.round(frame.top)}..${Math.round(frame.bottom)}, vertical inset ${inset}px`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const order = painted.indexOf(label)
|
|
97
|
+
for (const shape of painted) {
|
|
98
|
+
if (shape === label) continue
|
|
99
|
+
if (shape.tagName.toLowerCase() === 'text') continue
|
|
100
|
+
if (getComputedStyle(shape).fill === 'none') continue
|
|
101
|
+
if (!shape.getBoundingClientRect) continue
|
|
102
|
+
|
|
103
|
+
const box = shape.getBoundingClientRect()
|
|
104
|
+
if (box.width === 0 || box.height === 0) continue
|
|
105
|
+
|
|
106
|
+
const hit = overlap(rect, {
|
|
107
|
+
left: box.left,
|
|
108
|
+
right: box.right,
|
|
109
|
+
top: box.top,
|
|
110
|
+
bottom: box.bottom,
|
|
111
|
+
})
|
|
112
|
+
if (hit.x <= collision || hit.y <= collision) continue
|
|
113
|
+
|
|
114
|
+
const after = painted.indexOf(shape) > order
|
|
115
|
+
found.push({
|
|
116
|
+
selector: describe(label),
|
|
117
|
+
detail: after
|
|
118
|
+
? `is covered by a filled ${shape.tagName.toLowerCase()} painted after it`
|
|
119
|
+
: `sits over a filled ${shape.tagName.toLowerCase()} with no plate behind the glyphs`,
|
|
120
|
+
measured: `${Math.round(hit.x)}x${Math.round(hit.y)}px of overlap, vertical inset ${inset}px`,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (let first = 0; first < labels.length; first += 1) {
|
|
126
|
+
for (let second = first + 1; second < labels.length; second += 1) {
|
|
127
|
+
const a = labels[first]
|
|
128
|
+
const b = labels[second]
|
|
129
|
+
if (!a || !b) continue
|
|
130
|
+
|
|
131
|
+
const hit = overlap(insetRect(a), insetRect(b))
|
|
132
|
+
if (hit.x <= collision || hit.y <= collision) continue
|
|
133
|
+
|
|
134
|
+
found.push({
|
|
135
|
+
selector: describe(a),
|
|
136
|
+
detail: `collides with ${describe(b)}`,
|
|
137
|
+
measured: `${Math.round(hit.x)}x${Math.round(hit.y)}px of overlap, past the ${collision}px threshold, vertical inset ${inset}px`,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return found
|
|
144
|
+
},
|
|
145
|
+
[VERTICAL_INSET_PX, COLLISION_PX] as [number, number],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return rows.map((row) => ({ probe: 'diagram-geometry' as const, ...row }))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reports a stroke crossing a label, sampled along the stroke's own geometry.
|
|
153
|
+
*
|
|
154
|
+
* A bounding box cannot answer this. The first version of the occlusion probe
|
|
155
|
+
* counted a shape as occluding only when it carried a fill, which filtered out
|
|
156
|
+
* every `line` and every `fill="none"` panel border before the comparison ran,
|
|
157
|
+
* and a label sitting on an arrow passed every run until a person saw it. A
|
|
158
|
+
* diagonal connector's box also covers most of the diagram while the stroke
|
|
159
|
+
* itself touches almost none of it, so the box would report the opposite error
|
|
160
|
+
* once the fill filter came off.
|
|
161
|
+
*
|
|
162
|
+
* Both paint orders are findings. A stroke drawn after the label crosses the
|
|
163
|
+
* glyphs out, and one drawn before it shows through them, since SVG text
|
|
164
|
+
* carries no background plate of its own.
|
|
165
|
+
*/
|
|
166
|
+
export async function probeDiagramStrokes(page: Page): Promise<Finding[]> {
|
|
167
|
+
const rows = await page.evaluate(
|
|
168
|
+
async ([inset, interval, maxSamples]: [number, number, number]) => {
|
|
169
|
+
await document.fonts.ready
|
|
170
|
+
|
|
171
|
+
const describe = (element: Element): string => {
|
|
172
|
+
const owner = element.closest('svg')
|
|
173
|
+
const label = (element.textContent ?? '').trim().slice(0, 40)
|
|
174
|
+
const id = owner?.id ? `svg#${owner.id}` : 'svg'
|
|
175
|
+
return label ? `${id} text "${label}"` : `${id} text`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const insetRect = (element: Element) => {
|
|
179
|
+
const rect = element.getBoundingClientRect()
|
|
180
|
+
return {
|
|
181
|
+
left: rect.left,
|
|
182
|
+
right: rect.right,
|
|
183
|
+
top: rect.top + inset,
|
|
184
|
+
bottom: rect.bottom - inset,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const found: { selector: string; detail: string; measured: string }[] = []
|
|
189
|
+
|
|
190
|
+
for (const root of Array.from(document.querySelectorAll('svg'))) {
|
|
191
|
+
const painted = Array.from(root.querySelectorAll('*'))
|
|
192
|
+
const labels = Array.from(root.querySelectorAll('text'))
|
|
193
|
+
if (labels.length === 0) continue
|
|
194
|
+
|
|
195
|
+
for (const shape of painted) {
|
|
196
|
+
if (!(shape instanceof SVGGeometryElement)) continue
|
|
197
|
+
if (getComputedStyle(shape).stroke === 'none') continue
|
|
198
|
+
|
|
199
|
+
// Read off the shape rather than off the root, because
|
|
200
|
+
// `getPointAtLength` answers in the shape's own user space and a
|
|
201
|
+
// generated diagram nests almost everything under a transformed `g`.
|
|
202
|
+
// The root's matrix drops that translation, which puts every sampled
|
|
203
|
+
// point somewhere the label is not and reports the page clean.
|
|
204
|
+
const matrix = shape.getScreenCTM()
|
|
205
|
+
if (!matrix) continue
|
|
206
|
+
|
|
207
|
+
let length = 0
|
|
208
|
+
try {
|
|
209
|
+
length = shape.getTotalLength()
|
|
210
|
+
} catch {
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (length === 0) continue
|
|
214
|
+
|
|
215
|
+
const samples = Math.min(
|
|
216
|
+
maxSamples,
|
|
217
|
+
Math.max(2, Math.ceil(length / interval)),
|
|
218
|
+
)
|
|
219
|
+
const order = painted.indexOf(shape)
|
|
220
|
+
const crossed = new Map<Element, number>()
|
|
221
|
+
|
|
222
|
+
for (let step = 0; step <= samples; step += 1) {
|
|
223
|
+
const point = shape.getPointAtLength((length * step) / samples)
|
|
224
|
+
const screen = new DOMPoint(point.x, point.y).matrixTransform(
|
|
225
|
+
matrix,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
for (const label of labels) {
|
|
229
|
+
const rect = insetRect(label)
|
|
230
|
+
if (rect.bottom <= rect.top) continue
|
|
231
|
+
if (screen.x < rect.left || screen.x > rect.right) continue
|
|
232
|
+
if (screen.y < rect.top || screen.y > rect.bottom) continue
|
|
233
|
+
crossed.set(label, (crossed.get(label) ?? 0) + 1)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const [label, hits] of crossed) {
|
|
238
|
+
const after = order > painted.indexOf(label)
|
|
239
|
+
found.push({
|
|
240
|
+
selector: describe(label),
|
|
241
|
+
detail: after
|
|
242
|
+
? `is crossed out by a ${shape.tagName.toLowerCase()} stroked after it`
|
|
243
|
+
: `is painted over a ${shape.tagName.toLowerCase()} stroke that shows through the glyphs`,
|
|
244
|
+
measured: `${hits} of ${samples + 1} points sampled along the stroke land inside the label, vertical inset ${inset}px`,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return found
|
|
251
|
+
},
|
|
252
|
+
[VERTICAL_INSET_PX, STROKE_SAMPLE_PX, MAX_STROKE_SAMPLES] as [
|
|
253
|
+
number,
|
|
254
|
+
number,
|
|
255
|
+
number,
|
|
256
|
+
],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return rows.map((row) => ({ probe: 'diagram-strokes' as const, ...row }))
|
|
260
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core'
|
|
2
|
+
import { enterKeyboardModality } from '@/browser/engine'
|
|
3
|
+
import type { Finding } from '@/driver/steps'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports every element that takes focus and changes nothing a person can see.
|
|
7
|
+
*
|
|
8
|
+
* The lesson is in the first line of the run rather than in the comparison. A
|
|
9
|
+
* probe that focuses each element and reads its computed outline reports every
|
|
10
|
+
* correctly styled element as unstyled, because `:focus-visible` does not match
|
|
11
|
+
* a scripted `.focus()` while the browser is in pointer modality. That version
|
|
12
|
+
* produced 27 false findings against a page whose rings were all present. One
|
|
13
|
+
* Tab press ahead of the read puts the page in keyboard modality and the rule
|
|
14
|
+
* matches for the rest of the pass, which is why `enterKeyboardModality` is a
|
|
15
|
+
* shared helper rather than a line here.
|
|
16
|
+
*
|
|
17
|
+
* The reader below is serialized to source and evaluated by the browser, so
|
|
18
|
+
* every helper it needs is declared inside its own body. A reference to
|
|
19
|
+
* anything at module scope typechecks and throws once the page calls it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The elements a keyboard reaches without the page authoring a tab order. */
|
|
23
|
+
const FOCUSABLE =
|
|
24
|
+
'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'
|
|
25
|
+
|
|
26
|
+
/** What a ring is allowed to move, read at rest and again under focus. */
|
|
27
|
+
const PROPERTIES = [
|
|
28
|
+
'outlineStyle',
|
|
29
|
+
'outlineWidth',
|
|
30
|
+
'outlineColor',
|
|
31
|
+
'outlineOffset',
|
|
32
|
+
'boxShadow',
|
|
33
|
+
'borderColor',
|
|
34
|
+
'backgroundColor',
|
|
35
|
+
'color',
|
|
36
|
+
'textDecorationLine',
|
|
37
|
+
] as const
|
|
38
|
+
|
|
39
|
+
export async function probeFocus(page: Page): Promise<Finding[]> {
|
|
40
|
+
await enterKeyboardModality(page)
|
|
41
|
+
|
|
42
|
+
const rows = await page.evaluate(
|
|
43
|
+
([query, properties]: [string, readonly string[]]) => {
|
|
44
|
+
const describe = (element: Element): string => {
|
|
45
|
+
const tag = element.tagName.toLowerCase()
|
|
46
|
+
if (element.id) return `${tag}#${element.id}`
|
|
47
|
+
const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
|
|
48
|
+
return className ? `${tag}.${className}` : tag
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const snapshot = (element: Element): string[] => {
|
|
52
|
+
const computed = getComputedStyle(element)
|
|
53
|
+
return properties.map(
|
|
54
|
+
(property) => computed[property as 'color'] as string,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The modality press left one element focused, and reading its rest state
|
|
59
|
+
// while it still holds focus reports no difference for whatever ring it
|
|
60
|
+
// actually draws. Every element starts and ends this pass blurred.
|
|
61
|
+
const entry = document.activeElement
|
|
62
|
+
if (entry instanceof HTMLElement) entry.blur()
|
|
63
|
+
|
|
64
|
+
const found: { selector: string; visible: boolean; detail: string }[] = []
|
|
65
|
+
|
|
66
|
+
for (const element of Array.from(document.querySelectorAll(query))) {
|
|
67
|
+
if (!(element instanceof HTMLElement)) continue
|
|
68
|
+
// Tested by whether the element paints a box rather than by
|
|
69
|
+
// `offsetParent`, which is null for anything positioned `fixed` and
|
|
70
|
+
// would skip a pinned control that is plainly on screen.
|
|
71
|
+
if (element.getClientRects().length === 0) continue
|
|
72
|
+
if (getComputedStyle(element).visibility === 'hidden') continue
|
|
73
|
+
|
|
74
|
+
const rest = snapshot(element)
|
|
75
|
+
element.focus()
|
|
76
|
+
// An element the browser refuses to focus is skipped rather than
|
|
77
|
+
// reported. A disabled control has no ring by design, and folding it
|
|
78
|
+
// into the same row as a control that should have one and does not
|
|
79
|
+
// gives two findings one remedy.
|
|
80
|
+
if (document.activeElement !== element) {
|
|
81
|
+
element.blur()
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const focused = snapshot(element)
|
|
86
|
+
// An outline that resolves to `none` paints nothing, so a width, a
|
|
87
|
+
// color, or an offset moving underneath it is a computed difference a
|
|
88
|
+
// person cannot see. A stylesheet that clears `outline` for one control
|
|
89
|
+
// while a broader rule still sets `outline-offset` produces exactly
|
|
90
|
+
// that, and counting it would report the control as correctly ringed.
|
|
91
|
+
const blind =
|
|
92
|
+
focused[properties.indexOf('outlineStyle')] === 'none'
|
|
93
|
+
? (property: string) => property.startsWith('outline')
|
|
94
|
+
: () => false
|
|
95
|
+
const moved = properties.filter(
|
|
96
|
+
(property, index) =>
|
|
97
|
+
rest[index] !== focused[index] && !blind(property),
|
|
98
|
+
)
|
|
99
|
+
element.blur()
|
|
100
|
+
|
|
101
|
+
found.push({
|
|
102
|
+
selector: describe(element),
|
|
103
|
+
visible: moved.length > 0,
|
|
104
|
+
detail: moved.join(', '),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return found
|
|
109
|
+
},
|
|
110
|
+
[FOCUSABLE, PROPERTIES] as [string, readonly string[]],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return rows
|
|
114
|
+
.filter((row) => !row.visible)
|
|
115
|
+
.map((row) => ({
|
|
116
|
+
probe: 'focus' as const,
|
|
117
|
+
selector: row.selector,
|
|
118
|
+
detail: 'takes keyboard focus and changes nothing visible',
|
|
119
|
+
measured: `no movement across ${PROPERTIES.length} properties under :focus-visible`,
|
|
120
|
+
}))
|
|
121
|
+
}
|