@adia-ai/mcp 0.8.37 → 0.8.39
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/CHANGELOG.md +44 -27
- package/README.md +94 -29
- package/TOOLS.md +178 -16
- package/bin/adia-mcp +17 -8
- package/factory/public-surface.json +20 -0
- package/factory/resources/data-wiring.md +108 -0
- package/factory/resources/pattern-index.md +786 -0
- package/factory/resources/shell-selection.md +86 -0
- package/factory/resources/token-pairing-laws.md +68 -0
- package/factory/server.d.ts +12 -0
- package/factory/server.js +72 -0
- package/factory/tools/factory.js +277 -0
- package/factory/vendor/MANIFEST.json +29 -0
- package/factory/vendor/adia-contract-check.mjs +432 -0
- package/factory/vendor/adia-info +312 -0
- package/factory/vendor/adia-lint +396 -0
- package/factory/vendor/adia-probe.mjs +413 -0
- package/factory/vendor/adia-scaffold +801 -0
- package/factory/vendor/record-lint +278 -0
- package/gen-ui/server.d.ts +1 -1
- package/gen-ui/tools/corpus.js +1 -1
- package/gen-ui/tools/discovery.js +82 -0
- package/gen-ui/tools/feedback.js +1 -1
- package/package.json +13 -3
- package/protocol/server.d.ts +3 -2
- package/protocol/server.js +1 -1
- package/protocol/tools/protocol.js +123 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// adia-probe — the surface-qa browser gate, shipped (factory-audit Wave 2, gh#259).
|
|
3
|
+
//
|
|
4
|
+
// Runs the four-gate probe the surface-qa skill defines (references/
|
|
5
|
+
// verification.md) and emits a VerifyProof: zero console/page errors,
|
|
6
|
+
// non-zero bounding boxes on the named selectors, a deviceScaleFactor:2
|
|
7
|
+
// screenshot — and an explicit HUMAN/MODEL-READS-THE-IMAGE slot, because a
|
|
8
|
+
// probe that screenshots but doesn't read the image has verified nothing.
|
|
9
|
+
// It also records navigation timing as an ADVISORY perf row (REQ-06,
|
|
10
|
+
// gh#1200/gh#1211, realizing ADR-0040's ui-verifier "perf" clause) — a
|
|
11
|
+
// budget observation only; it never flips the verdict, since promoting it
|
|
12
|
+
// to a blocking gate is a later ruling that needs real data first.
|
|
13
|
+
//
|
|
14
|
+
// gh#1259 adds the instrumented AA-contrast slice — the one VerifyProof row
|
|
15
|
+
// QA seats carried as UNMEASURED or hand-computed (the #1246 wave's probes
|
|
16
|
+
// 4/5). It samples rendered foreground/background pairs for visible text
|
|
17
|
+
// nodes (computed sRGB, alpha-composited up to the first opaque ancestor
|
|
18
|
+
// background — the math probe 5 hand-executed), scores each pair against
|
|
19
|
+
// WCAG AA (4.5 normal / 3.0 large text: ≥18pt, or ≥14pt bold), and reports
|
|
20
|
+
// pass/fail per pair with the sampled values. Unlike perf, contrast is a
|
|
21
|
+
// GATE when measured: a failing pair fails the verdict (it is the a11y
|
|
22
|
+
// rubric row's contrast slice, and that row is blocking). When no samples
|
|
23
|
+
// can be captured the row degrades to UNMEASURED, never a silent fail.
|
|
24
|
+
// Pairs whose background chain crosses a background-image before an opaque
|
|
25
|
+
// color are skipped as indeterminate (computed styles can't see pixels of
|
|
26
|
+
// an image) — skipped counts are reported, and those pairs stay the
|
|
27
|
+
// image-reader's judgment.
|
|
28
|
+
//
|
|
29
|
+
// Requires Playwright IN THE CONSUMER APP (a dev dependency it very likely
|
|
30
|
+
// already has; this plugin ships no browser): npm i -D playwright
|
|
31
|
+
//
|
|
32
|
+
// Usage:
|
|
33
|
+
// node adia-probe.mjs <url> --selector <css> [--selector <css> …]
|
|
34
|
+
// [--screenshot probe.png] [--perf-budget-ms 3000] [--json]
|
|
35
|
+
// node adia-probe.mjs selftest # gate logic + report shape (no browser)
|
|
36
|
+
//
|
|
37
|
+
// Exit: 0 = mechanized gates pass (verdict "pass-pending-read" — gate 3's
|
|
38
|
+
// image read remains the model's), 1 = a gate failed, 2 = setup error.
|
|
39
|
+
// The perf row is ADVISORY: exceeding perfBudgetMs never changes this exit
|
|
40
|
+
// code or the verdict string — it is reported, not enforced.
|
|
41
|
+
|
|
42
|
+
import process from 'node:process';
|
|
43
|
+
|
|
44
|
+
// REQ-06 default budget: generous on purpose (advisory, not a floor) — a
|
|
45
|
+
// number worth reporting against without a real dataset to justify a
|
|
46
|
+
// tighter one yet. Overridable per invocation via --perf-budget-ms.
|
|
47
|
+
const DEFAULT_PERF_BUDGET_MS = 3000;
|
|
48
|
+
|
|
49
|
+
// ---- WCAG AA contrast math (gh#1259) — pure, browser-free, selftestable ----
|
|
50
|
+
|
|
51
|
+
// sRGB channel linearization (WCAG 2.x relative-luminance definition).
|
|
52
|
+
export function srgbChannel(c8) {
|
|
53
|
+
const c = c8 / 255;
|
|
54
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function relativeLuminance([r, g, b]) {
|
|
58
|
+
return 0.2126 * srgbChannel(r) + 0.7152 * srgbChannel(g) + 0.0722 * srgbChannel(b);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function contrastRatio(fg, bg) {
|
|
62
|
+
const a = relativeLuminance(fg);
|
|
63
|
+
const b = relativeLuminance(bg);
|
|
64
|
+
const [hi, lo] = a >= b ? [a, b] : [b, a];
|
|
65
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// WCAG "large text": ≥18pt (24px), or ≥14pt (18.66px) bold. AA then asks
|
|
69
|
+
// 3.0 instead of 4.5.
|
|
70
|
+
export function isLargeText(fontSizePx, fontWeight) {
|
|
71
|
+
return fontSizePx >= 24 || (fontSizePx >= 18.66 && fontWeight >= 700);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// samples: [{ text, element, fg:[r,g,b], bg:[r,g,b], fontSizePx, fontWeight }]
|
|
75
|
+
export function scoreContrastSamples(samples, { skipped = 0 } = {}) {
|
|
76
|
+
const pairs = samples.map((s) => {
|
|
77
|
+
const large = isLargeText(s.fontSizePx, s.fontWeight);
|
|
78
|
+
const required = large ? 3.0 : 4.5;
|
|
79
|
+
const ratio = Math.round(contrastRatio(s.fg, s.bg) * 100) / 100;
|
|
80
|
+
return { ...s, large, required, ratio, pass: ratio >= required };
|
|
81
|
+
});
|
|
82
|
+
const failing = pairs.filter((p) => !p.pass);
|
|
83
|
+
return {
|
|
84
|
+
pass: failing.length === 0,
|
|
85
|
+
standard: 'WCAG AA — 4.5:1 normal / 3.0:1 large text (≥18pt, or ≥14pt bold)',
|
|
86
|
+
checked: pairs.length,
|
|
87
|
+
skippedImageBacked: skipped,
|
|
88
|
+
failing,
|
|
89
|
+
pairs,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildProof({ url, errors, boxes, screenshotPath, navTiming, perfBudgetMs = DEFAULT_PERF_BUDGET_MS, contrastSamples = null, contrastSkipped = 0 }) {
|
|
94
|
+
const consolePass = errors.length === 0;
|
|
95
|
+
const boxFailures = Object.entries(boxes)
|
|
96
|
+
.filter(([, b]) => !b || b.width <= 0 || b.height <= 0)
|
|
97
|
+
.map(([sel]) => sel);
|
|
98
|
+
const boxPass = boxFailures.length === 0;
|
|
99
|
+
// ADVISORY — REQ-06: reports navigation timing against a budget but never
|
|
100
|
+
// participates in `verdict` below. `navTiming` missing/null means the
|
|
101
|
+
// caller couldn't measure it (e.g. the selftest's synthetic fixtures);
|
|
102
|
+
// that is UNMEASURED, not a failure — a VerifyProof still carries the row.
|
|
103
|
+
const perf = navTiming
|
|
104
|
+
? {
|
|
105
|
+
advisory: true,
|
|
106
|
+
budgetMs: perfBudgetMs,
|
|
107
|
+
observed: navTiming,
|
|
108
|
+
withinBudget: navTiming.loadMs <= perfBudgetMs,
|
|
109
|
+
}
|
|
110
|
+
: { advisory: true, budgetMs: perfBudgetMs, observed: null, withinBudget: null, reason: 'UNMEASURED — no navigation timing captured' };
|
|
111
|
+
// gh#1259 — GATE when measured, UNMEASURED (not failing) when the caller
|
|
112
|
+
// captured no samples (selftest fixtures, an all-image page, a sampler
|
|
113
|
+
// crash). This mirrors the perf row's degradation shape but NOT its
|
|
114
|
+
// advisory nature: a measured failing pair fails the verdict.
|
|
115
|
+
const contrast = contrastSamples
|
|
116
|
+
? scoreContrastSamples(contrastSamples, { skipped: contrastSkipped })
|
|
117
|
+
: { pass: null, reason: 'UNMEASURED — no text foreground/background pairs sampled' };
|
|
118
|
+
return {
|
|
119
|
+
record: 'VerifyProof',
|
|
120
|
+
url,
|
|
121
|
+
gates: {
|
|
122
|
+
consoleErrors: { pass: consolePass, errors },
|
|
123
|
+
boundingBoxes: { pass: boxPass, failing: boxFailures, boxes },
|
|
124
|
+
screenshot: { path: screenshotPath, deviceScaleFactor: 2 },
|
|
125
|
+
imageRead: 'REQUIRED — a screenshot nobody reads verifies nothing; the reader states what the pixels show',
|
|
126
|
+
perf,
|
|
127
|
+
contrast,
|
|
128
|
+
},
|
|
129
|
+
// perf is deliberately excluded here — advisory means it can never turn
|
|
130
|
+
// a green gate red (REQ-06's "Advisory means" clause). contrast joins the
|
|
131
|
+
// verdict only when measured: `pass === false` fails; `null` (UNMEASURED)
|
|
132
|
+
// does not (gh#1259).
|
|
133
|
+
verdict: consolePass && boxPass && contrast.pass !== false ? 'pass-pending-read' : 'fail',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function selftest() {
|
|
138
|
+
const good = buildProof({
|
|
139
|
+
url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
|
|
140
|
+
navTiming: { loadMs: 120, domContentLoadedMs: 80 },
|
|
141
|
+
});
|
|
142
|
+
const badBox = buildProof({
|
|
143
|
+
url: 'http://x', errors: [], boxes: { 'my-surface': { width: 0, height: 0 } }, screenshotPath: 'p.png',
|
|
144
|
+
});
|
|
145
|
+
const badErr = buildProof({
|
|
146
|
+
url: 'http://x', errors: ['TypeError: boom'], boxes: { 'my-surface': { width: 1, height: 1 } }, screenshotPath: 'p.png',
|
|
147
|
+
});
|
|
148
|
+
// REQ-06: a slow page (over budget) still PASSES the mechanized gates —
|
|
149
|
+
// advisory means perf never turns a green gate red.
|
|
150
|
+
const overBudget = buildProof({
|
|
151
|
+
url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
|
|
152
|
+
navTiming: { loadMs: 9000, domContentLoadedMs: 8000 }, perfBudgetMs: 3000,
|
|
153
|
+
});
|
|
154
|
+
const noTiming = buildProof({
|
|
155
|
+
url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
|
|
156
|
+
});
|
|
157
|
+
// gh#1259 — contrast fixtures. Positive control: black-on-white text
|
|
158
|
+
// (ratio 21:1) passes and leaves the verdict green.
|
|
159
|
+
const goodContrast = buildProof({
|
|
160
|
+
url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
|
|
161
|
+
contrastSamples: [
|
|
162
|
+
{ text: 'readable body copy', element: 'p', fg: [0, 0, 0], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
// Negative control: #aaa on white (≈2.32:1) — a deliberately-failing
|
|
166
|
+
// low-contrast pair MUST fail the contrast gate AND flip the verdict.
|
|
167
|
+
const badContrast = buildProof({
|
|
168
|
+
url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
|
|
169
|
+
contrastSamples: [
|
|
170
|
+
{ text: 'faint caption', element: 'span', fg: [170, 170, 170], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
// Large-text threshold: #949494 on white (≈3.03:1) fails at 16px normal
|
|
174
|
+
// (needs 4.5) but passes at 24px (large text, needs 3.0).
|
|
175
|
+
const borderline = scoreContrastSamples([
|
|
176
|
+
{ text: 'gray at body size', element: 'p', fg: [148, 148, 148], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
|
|
177
|
+
{ text: 'gray as a heading', element: 'h1', fg: [148, 148, 148], bg: [255, 255, 255], fontSizePx: 24, fontWeight: 400 },
|
|
178
|
+
]);
|
|
179
|
+
const fails = [];
|
|
180
|
+
const bw = contrastRatio([0, 0, 0], [255, 255, 255]);
|
|
181
|
+
if (bw < 20.9 || bw > 21.1) fails.push(`black/white ratio ${bw} not ≈21`);
|
|
182
|
+
if (!isLargeText(24, 400) || !isLargeText(19, 700) || isLargeText(19, 400) || isLargeText(16, 400)) {
|
|
183
|
+
fails.push('large-text threshold (≥18pt, or ≥14pt bold) mis-scored');
|
|
184
|
+
}
|
|
185
|
+
if (goodContrast.verdict !== 'pass-pending-read' || goodContrast.gates.contrast.pass !== true) {
|
|
186
|
+
fails.push('passing contrast pair did not pass');
|
|
187
|
+
}
|
|
188
|
+
if (badContrast.verdict !== 'fail' || badContrast.gates.contrast.pass !== false) {
|
|
189
|
+
fails.push('NEGATIVE CONTROL: low-contrast pair did not fail the verdict');
|
|
190
|
+
}
|
|
191
|
+
if (badContrast.gates.contrast.failing.length !== 1 || badContrast.gates.contrast.failing[0].required !== 4.5
|
|
192
|
+
|| badContrast.gates.contrast.failing[0].ratio >= 4.5) {
|
|
193
|
+
fails.push('failing pair not reported with sampled ratio + required threshold');
|
|
194
|
+
}
|
|
195
|
+
if (borderline.pairs[0].pass !== false || borderline.pairs[1].pass !== true || borderline.pairs[1].required !== 3.0) {
|
|
196
|
+
fails.push('large-text 3.0 exception not applied per pair');
|
|
197
|
+
}
|
|
198
|
+
if (good.gates.contrast.pass !== null || !good.gates.contrast.reason.includes('UNMEASURED')) {
|
|
199
|
+
fails.push('missing contrast samples did not degrade to UNMEASURED contrast row');
|
|
200
|
+
}
|
|
201
|
+
if (good.verdict !== 'pass-pending-read') fails.push('good fixture not pass-pending-read');
|
|
202
|
+
if (badBox.verdict !== 'fail' || badBox.gates.boundingBoxes.failing[0] !== 'my-surface') fails.push('0x0 box not failed');
|
|
203
|
+
if (badErr.verdict !== 'fail' || badErr.gates.consoleErrors.pass) fails.push('console error not failed');
|
|
204
|
+
if (!good.gates.imageRead.includes('REQUIRED')) fails.push('imageRead slot missing');
|
|
205
|
+
if (!good.gates.perf || good.gates.perf.advisory !== true) fails.push('perf row missing/not advisory');
|
|
206
|
+
if (good.gates.perf.withinBudget !== true) fails.push('perf row withinBudget mis-scored for a fast fixture');
|
|
207
|
+
if (overBudget.verdict !== 'pass-pending-read') fails.push('REQ-06 REGRESSION: over-budget perf turned a green gate red');
|
|
208
|
+
if (overBudget.gates.perf.withinBudget !== false) fails.push('over-budget perf row not flagged withinBudget:false');
|
|
209
|
+
if (noTiming.gates.perf.observed !== null || !noTiming.gates.perf.reason.includes('UNMEASURED')) {
|
|
210
|
+
fails.push('missing navTiming did not degrade to UNMEASURED perf row');
|
|
211
|
+
}
|
|
212
|
+
if (fails.length) {
|
|
213
|
+
console.error('selftest FAIL: ' + fails.join(' | '));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
console.log('selftest OK — 8 fixtures, verdicts + imageRead + advisory perf row + AA contrast gate (positive/negative/large-text) correct');
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
const argv = process.argv.slice(2);
|
|
222
|
+
if (argv[0] === 'selftest') selftest();
|
|
223
|
+
// REQ-06 (gh#1122): help is not a usage error — checked BEFORE url/selector
|
|
224
|
+
// resolution so -h/--help can never be mistaken for a positional url (a
|
|
225
|
+
// single-dash flag doesn't start with '--', so it would otherwise fall
|
|
226
|
+
// through to the `.find` below) and exits 0, not the 2 reserved for a
|
|
227
|
+
// genuine missing-argument mis-invocation.
|
|
228
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
229
|
+
console.log('usage: adia-probe.mjs <url> --selector <css> [--selector <css> …] [--screenshot out.png] [--perf-budget-ms 3000] [--json]');
|
|
230
|
+
process.exit(0);
|
|
231
|
+
}
|
|
232
|
+
const url = argv.find((a) => !a.startsWith('--'));
|
|
233
|
+
const selectors = argv.flatMap((a, i) => (a === '--selector' ? [argv[i + 1]] : []));
|
|
234
|
+
const screenshotPath = argv.includes('--screenshot')
|
|
235
|
+
? argv[argv.indexOf('--screenshot') + 1] : 'probe.png';
|
|
236
|
+
// REQ-06: advisory only — an unparsable/absent value falls back to the
|
|
237
|
+
// default rather than erroring, since this flag never gates anything.
|
|
238
|
+
const perfBudgetMs = argv.includes('--perf-budget-ms')
|
|
239
|
+
? Number(argv[argv.indexOf('--perf-budget-ms') + 1]) || DEFAULT_PERF_BUDGET_MS
|
|
240
|
+
: DEFAULT_PERF_BUDGET_MS;
|
|
241
|
+
if (!url || selectors.length === 0) {
|
|
242
|
+
console.error('usage: adia-probe.mjs <url> --selector <css> [--selector <css> …] [--screenshot out.png] [--perf-budget-ms 3000] [--json]');
|
|
243
|
+
process.exit(2);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Resolve playwright from the CONSUMER APP (cwd), not from this file's own
|
|
247
|
+
// location. A bare import('playwright') searches this script's ancestor
|
|
248
|
+
// directories — correct when the probe runs from inside the app's tree,
|
|
249
|
+
// wrong when a packed copy lives elsewhere (e.g. @adia-ai/mcp's vendored
|
|
250
|
+
// copy under an npx cache, PR #1289 review): the app's devDependency would
|
|
251
|
+
// never be found. Try the bare import first (fast path, zero behavior
|
|
252
|
+
// change in the plugin's own layout), then fall back to a resolver rooted
|
|
253
|
+
// at process.cwd().
|
|
254
|
+
let chromium;
|
|
255
|
+
try {
|
|
256
|
+
({ chromium } = await import('playwright'));
|
|
257
|
+
} catch {
|
|
258
|
+
try {
|
|
259
|
+
const { createRequire } = await import('node:module');
|
|
260
|
+
const { pathToFileURL } = await import('node:url');
|
|
261
|
+
const path = await import('node:path');
|
|
262
|
+
const req = createRequire(path.join(process.cwd(), 'package.json'));
|
|
263
|
+
({ chromium } = await import(pathToFileURL(req.resolve('playwright')).href));
|
|
264
|
+
} catch {
|
|
265
|
+
console.error('adia-probe: playwright not installed in this app — npm i -D playwright');
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const browser = await chromium.launch();
|
|
271
|
+
// deviceScaleFactor must be set at CONTEXT creation — scale:'device' on a
|
|
272
|
+
// default context captures at 1x (the defect this script replaced).
|
|
273
|
+
const context = await browser.newContext({ deviceScaleFactor: 2 });
|
|
274
|
+
const page = await context.newPage();
|
|
275
|
+
const errors = [];
|
|
276
|
+
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
|
|
277
|
+
page.on('pageerror', (e) => errors.push(String(e)));
|
|
278
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
279
|
+
|
|
280
|
+
const boxes = {};
|
|
281
|
+
for (const sel of selectors) {
|
|
282
|
+
boxes[sel] = await page.locator(sel).first().boundingBox().catch(() => null);
|
|
283
|
+
}
|
|
284
|
+
// REQ-06 — Navigation Timing Level 2 (the non-deprecated API); `null` on
|
|
285
|
+
// a page that hasn't finished loading or a substrate that doesn't expose
|
|
286
|
+
// it, which `buildProof` degrades to an UNMEASURED (not failing) perf row.
|
|
287
|
+
const navTiming = await page.evaluate(() => {
|
|
288
|
+
const [entry] = performance.getEntriesByType('navigation');
|
|
289
|
+
if (!entry) return null;
|
|
290
|
+
return {
|
|
291
|
+
loadMs: Math.round(entry.loadEventEnd - entry.startTime),
|
|
292
|
+
domContentLoadedMs: Math.round(entry.domContentLoadedEventEnd - entry.startTime),
|
|
293
|
+
responseMs: Math.round(entry.responseEnd - entry.startTime),
|
|
294
|
+
};
|
|
295
|
+
}).catch(() => null);
|
|
296
|
+
// gh#1259 — sample rendered fg/bg pairs for visible text nodes. The
|
|
297
|
+
// evaluate callback is self-contained (it serializes into the page; module
|
|
298
|
+
// scope is unreachable there): it collects RAW sampled values only, and the
|
|
299
|
+
// WCAG scoring runs in Node via the exported, selftested functions above.
|
|
300
|
+
// Composite backgrounds: walk up to the first opaque ancestor
|
|
301
|
+
// background-color, alpha-compositing translucent layers (over white, the
|
|
302
|
+
// document default). A background-image anywhere in that chain makes the
|
|
303
|
+
// pair indeterminate from computed styles — skipped and counted, never
|
|
304
|
+
// guessed. Deduped by (fg, bg, size, weight) and capped, so the report
|
|
305
|
+
// stays bounded on long pages.
|
|
306
|
+
const sampled = await page.evaluate(() => {
|
|
307
|
+
const parse = (s) => {
|
|
308
|
+
const m = /rgba?\(([^)]+)\)/.exec(s || '');
|
|
309
|
+
if (!m) return null;
|
|
310
|
+
const [r, g, b, a = 1] = m[1].split(',').map(Number);
|
|
311
|
+
return { rgb: [r, g, b], a };
|
|
312
|
+
};
|
|
313
|
+
const over = (fg, a, bg) => fg.map((c, i) => Math.round(c * a + bg[i] * (1 - a)));
|
|
314
|
+
const bgFor = (el) => {
|
|
315
|
+
const layers = [];
|
|
316
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null) {
|
|
317
|
+
const cs = getComputedStyle(n);
|
|
318
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') return null; // indeterminate — pixels, not computed styles
|
|
319
|
+
const c = parse(cs.backgroundColor);
|
|
320
|
+
if (c && c.a > 0) {
|
|
321
|
+
layers.push(c);
|
|
322
|
+
if (c.a >= 1) break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
let bg = [255, 255, 255];
|
|
326
|
+
for (let i = layers.length - 1; i >= 0; i--) bg = over(layers[i].rgb, layers[i].a, bg);
|
|
327
|
+
return bg;
|
|
328
|
+
};
|
|
329
|
+
const samples = [];
|
|
330
|
+
let skipped = 0;
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
333
|
+
let node;
|
|
334
|
+
while ((node = walker.nextNode()) && samples.length < 120) {
|
|
335
|
+
const text = node.textContent.trim();
|
|
336
|
+
if (!text) continue;
|
|
337
|
+
const el = node.parentElement;
|
|
338
|
+
if (!el || ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(el.tagName)) continue;
|
|
339
|
+
const cs = getComputedStyle(el);
|
|
340
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue;
|
|
341
|
+
const rect = el.getBoundingClientRect();
|
|
342
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
343
|
+
const fgc = parse(cs.color);
|
|
344
|
+
if (!fgc) continue;
|
|
345
|
+
const bg = bgFor(el);
|
|
346
|
+
if (!bg) { skipped += 1; continue; }
|
|
347
|
+
const fg = fgc.a >= 1 ? fgc.rgb.map(Math.round) : over(fgc.rgb, fgc.a, bg);
|
|
348
|
+
const fontSizePx = parseFloat(cs.fontSize) || 16;
|
|
349
|
+
const fontWeight = cs.fontWeight === 'bold' ? 700 : Number(cs.fontWeight) || 400;
|
|
350
|
+
const key = `${fg.join()}|${bg.join()}|${fontSizePx}|${fontWeight}`;
|
|
351
|
+
if (seen.has(key)) continue;
|
|
352
|
+
seen.add(key);
|
|
353
|
+
samples.push({
|
|
354
|
+
text: text.slice(0, 40),
|
|
355
|
+
element: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
|
|
356
|
+
fg, bg, fontSizePx, fontWeight,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return { samples, skipped };
|
|
360
|
+
}).catch(() => null);
|
|
361
|
+
await page.screenshot({ path: screenshotPath, scale: 'device', fullPage: true });
|
|
362
|
+
await browser.close();
|
|
363
|
+
|
|
364
|
+
const proof = buildProof({
|
|
365
|
+
url, errors, boxes, screenshotPath, navTiming, perfBudgetMs,
|
|
366
|
+
contrastSamples: sampled && sampled.samples.length ? sampled.samples : null,
|
|
367
|
+
contrastSkipped: sampled ? sampled.skipped : 0,
|
|
368
|
+
});
|
|
369
|
+
if (process.argv.includes('--json')) {
|
|
370
|
+
console.log(JSON.stringify(proof, null, 1));
|
|
371
|
+
} else {
|
|
372
|
+
const perfNote = proof.gates.perf.observed
|
|
373
|
+
? `perf(advisory):${proof.gates.perf.observed.loadMs}ms/${proof.gates.perf.budgetMs}ms budget`
|
|
374
|
+
: 'perf(advisory):UNMEASURED';
|
|
375
|
+
const c = proof.gates.contrast;
|
|
376
|
+
const contrastNote = c.pass === null
|
|
377
|
+
? 'contrast:UNMEASURED'
|
|
378
|
+
: `contrast:${c.pass ? 'pass' : 'FAIL'} ${c.checked - c.failing.length}/${c.checked} pairs${c.skippedImageBacked ? ` (+${c.skippedImageBacked} image-backed skipped)` : ''}`;
|
|
379
|
+
console.log(`[adia-probe] ${proof.verdict} — errors:${errors.length} boxes:${selectors.length - proof.gates.boundingBoxes.failing.length}/${selectors.length} shot:${screenshotPath} ${perfNote} ${contrastNote}`);
|
|
380
|
+
if (proof.verdict === 'fail') {
|
|
381
|
+
for (const e of errors) console.log(` console: ${e}`);
|
|
382
|
+
for (const s of proof.gates.boundingBoxes.failing) console.log(` 0×0/missing: ${s}`);
|
|
383
|
+
if (c.pass === false) {
|
|
384
|
+
for (const p of c.failing) {
|
|
385
|
+
console.log(` contrast: ${p.ratio}:1 < ${p.required}:1 — ${p.element} "${p.text}" fg rgb(${p.fg.join(',')}) on bg rgb(${p.bg.join(',')}) @ ${p.fontSizePx}px w${p.fontWeight}${p.large ? ' (large)' : ''}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (proof.gates.perf.observed && !proof.gates.perf.withinBudget) {
|
|
390
|
+
console.log(` NOTE: perf row over advisory budget (${proof.gates.perf.observed.loadMs}ms > ${proof.gates.perf.budgetMs}ms) — advisory only, does not fail this gate.`);
|
|
391
|
+
}
|
|
392
|
+
console.log(` NEXT: read ${screenshotPath} — the proof is incomplete until the pixels are described.`);
|
|
393
|
+
}
|
|
394
|
+
process.exit(proof.verdict === 'fail' ? 1 : 0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Entry guard (PR #1289 review): this module exports pure contrast helpers
|
|
398
|
+
// (srgbChannel … buildProof) for tests and other tooling — an unguarded
|
|
399
|
+
// top-level main() would launch the CLI (and its process.exit(2) usage path)
|
|
400
|
+
// on ANY import. Canonical-path compare via realpathSync on both sides, the
|
|
401
|
+
// repo's standing rule, because skill invocation paths are symlinks
|
|
402
|
+
// (adia-contract-check.mjs carries the same guard).
|
|
403
|
+
const isMain = await (async () => {
|
|
404
|
+
if (!process.argv[1]) return false;
|
|
405
|
+
try {
|
|
406
|
+
const { realpathSync } = await import('node:fs');
|
|
407
|
+
const { fileURLToPath } = await import('node:url');
|
|
408
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
if (isMain) await main();
|