@webability/core 1.7.0 → 1.9.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.
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Rendered-pixel contrast judge for text on a gradient / image / overlapped
3
+ * background — the cases where axe reports `incomplete` and CSS cannot give a
4
+ * background colour.
5
+ *
6
+ * What the user actually sees behind the glyphs IS measurable: hide every
7
+ * glyph, screenshot the text's own boxes, and compare the text colour with
8
+ * the pixels that were behind it.
9
+ *
10
+ * - every pixel fails the threshold -> the text fails wherever it sits: VIOLATION
11
+ * - any pixel passes -> the background is not uniformly bad: DROP
12
+ * (a mixed gradient is not a confident failure, and never a pass we claim)
13
+ *
14
+ * Anything we cannot measure reliably (semi-transparent text, text over
15
+ * <video>/<canvas>/<iframe>, background-clip:text, transformed or clipped
16
+ * boxes, no screenshot support) is dropped, never judged.
17
+ */
18
+
19
+ interface PixelVerdict {
20
+ selector: string;
21
+ fail: boolean;
22
+ /** Best (highest) contrast ratio found anywhere behind the text. */
23
+ bestRatio: number;
24
+ /** Worst (lowest) contrast ratio found anywhere behind the text. */
25
+ worstRatio: number;
26
+ /** The background pixel behind the text that gave `worstRatio`, as rgb(r,g,b). */
27
+ worstBg?: string;
28
+ /** The background pixel behind the text that gave `bestRatio`, as rgb(r,g,b). */
29
+ bestBg?: string;
30
+ threshold: number;
31
+ fg: string;
32
+ }
33
+
34
+ /**
35
+ * AI judge for the undecided set.
36
+ *
37
+ * The scanner never hands the user a "needs review" bucket. Findings the
38
+ * engines could not decide (axe `incomplete`, a detector's `needsReview`)
39
+ * first go through the deterministic resolvers (rendered-pixel contrast,
40
+ * captions). What is still undecided after that reaches this judge, which
41
+ * turns each item into one of:
42
+ *
43
+ * - `violation` + a concrete fix → promoted into `issues[]`
44
+ * - `pass` + a one-line reason → recorded in `judged[]`, not reported
45
+ *
46
+ * Anything the judge cannot settle — no model configured, malformed output,
47
+ * timeout, a contrast background that cannot be sampled — is DROPPED, exactly
48
+ * as before. The judge never invents a verdict.
49
+ *
50
+ * Deterministic pre-judges run before any model call (declared-decorative
51
+ * images, images named by their link text, CSS backgrounds outside any
52
+ * control or on a control that already has a name, motion that a
53
+ * `prefers-reduced-motion: reduce` rule stops on that element). Contrast items carry a real pixel measurement
54
+ * (best/worst ratio behind the glyphs, AA threshold for the font size) so the
55
+ * verdict states a ratio the scanner measured, not one the model guessed.
56
+ *
57
+ * Models (local, via an OpenAI-compatible chat endpoint — OpenRouter by
58
+ * default): vision `anthropic/claude-haiku-4.5`, text-only
59
+ * `deepseek/deepseek-v4-flash`. Hosted: POST `${apiUrl}/api/judge-incomplete`.
60
+ */
61
+
62
+ declare const JUDGE_DEFAULT_TIMEOUT_MS = 20000;
63
+ declare const DEFAULT_JUDGE_BASE_URL = "https://openrouter.ai/api/v1";
64
+ declare const DEFAULT_JUDGE_VISION_MODEL = "anthropic/claude-haiku-4.5";
65
+ declare const DEFAULT_JUDGE_TEXT_MODEL = "deepseek/deepseek-v4-flash";
66
+ type JudgeVerdictKind = 'pass' | 'violation';
67
+ /** One judged finding, as recorded on `ScanResult.judged`. Small on purpose. */
68
+ interface JudgedEntry$1 {
69
+ id: string;
70
+ type: string;
71
+ verdict: JudgeVerdictKind;
72
+ reason: string;
73
+ }
74
+ /** One undecided finding as sent to the model / hosted endpoint. */
75
+ interface JudgeItem {
76
+ id: string;
77
+ type: string;
78
+ wcag: string;
79
+ message: string;
80
+ selector: string;
81
+ /** outerHTML, ≤400 chars */
82
+ html: string;
83
+ /** Computed context: colours/threshold/ratios for contrast, src/alt/link text for images, … */
84
+ context: Record<string, unknown>;
85
+ /** PNG, base64 (no data: prefix). Present only on the vision path. */
86
+ screenshot?: string;
87
+ }
88
+ interface JudgeRequest {
89
+ url: string;
90
+ level: WcagLevel;
91
+ vision: boolean;
92
+ items: JudgeItem[];
93
+ }
94
+ interface JudgeVerdict {
95
+ id: string;
96
+ verdict: JudgeVerdictKind;
97
+ reason?: string;
98
+ fix?: {
99
+ attribute?: string;
100
+ value?: string;
101
+ };
102
+ }
103
+ /** Returns the raw model output (a JSON array string) or an already-parsed `{verdicts}` / array. */
104
+ type JudgeTransport = (req: JudgeRequest, signal: AbortSignal) => Promise<unknown>;
105
+ interface JudgeOptions {
106
+ /** OpenAI-compatible chat-completions key (OpenRouter). Enables the local path. */
107
+ apiKey?: string;
108
+ /** Base URL of the chat-completions API. Default https://openrouter.ai/api/v1 */
109
+ baseUrl?: string;
110
+ visionModel?: string;
111
+ textModel?: string;
112
+ /** Hosted path: WebAbility API origin; POST `${apiUrl}/api/judge-incomplete`. */
113
+ apiUrl?: string;
114
+ /** Bearer token for the hosted path. */
115
+ apiToken?: string;
116
+ /**
117
+ * Hosted path only: the shared secret that identifies the hosted MCP server
118
+ * to the platform (sent as `x-mcp-internal`). Lets the platform judge scans
119
+ * that carry no user token. Never set on local installs.
120
+ */
121
+ internalSecret?: string;
122
+ /** Total judge budget per scan (all batches). Default 20 000 ms. */
123
+ timeoutMs?: number;
124
+ /** Conformance ceiling: only criteria at or below this level are promoted. Default AA. */
125
+ level?: WcagLevel;
126
+ /** false = HTML/text only (scan_html, DOM path). Default: screenshots when the page can take them. */
127
+ vision?: boolean;
128
+ /** Test / custom model injection. Wins over apiKey/apiUrl. */
129
+ transport?: JudgeTransport;
130
+ /**
131
+ * Largest element crop (base64 chars) to send. Default: JUDGE_MAX_CROP_CHARS on
132
+ * the hosted path (the endpoint answers 413 above it, PNG only), none elsewhere.
133
+ */
134
+ maxCropChars?: number;
135
+ }
136
+ interface JudgeRunOptions extends JudgeOptions {
137
+ url: string;
138
+ /** Pixel measurements already taken by the contrast resolver (by selector). */
139
+ measured?: Map<string, PixelVerdict>;
140
+ }
141
+ interface JudgeOutcome {
142
+ /** Verdict=violation, now confirmed findings with a fix. Not yet deduped against `issues`. */
143
+ promoted: ScanIssue[];
144
+ /** Every verdict reached (pass and violation), id = the original finding id. */
145
+ judged: JudgedEntry$1[];
146
+ }
147
+ declare function resetJudgeCache(): void;
148
+ /** The judge's system prompt. Exported so a server-side judge (platform /api/judge-incomplete) sends the same rules, not a copy. */
149
+ declare const JUDGE_SYSTEM_PROMPT = "You are the final accessibility judge for a WCAG scanner. Automated engines could not decide the findings below. You MUST decide each one: \"pass\" (not a violation) or \"violation\". There is no third option \u2014 never answer \"unsure\", never defer to a human.\n\nRules:\n- Images (1.1.1): \"pass\" when the image is decorative, purely presentational, a spacer/tracking pixel, or is named by the text of its enclosing link/button. Otherwise \"violation\" with fix.attribute=\"alt\" and a concrete alt text written from the image and its context: \u2264125 characters, no \"image of\"/\"picture of\" prefix, no file names.\n- CSS background images (1.1.1) on a control with no accessible name (context.control.accessibleName is empty): the image is the control's only content, so \"violation\" with fix.value = a short name for what the control does, written from the crop and nearby text (\u2264125 characters, no \"image of\"). \"pass\" only if the control is clearly not meant to be operated.\n- Images of text (1.4.5): logos, wordmarks and brand signatures are exempt \u2192 \"pass\". Essential informational text rendered as an image \u2192 \"violation\".\n- Text contrast (1.4.3 / 1.4.6): you receive the text colour, the required ratio for its font size, and the best/worst measured ratio behind the glyphs plus a crop. \"violation\" when the text is not readable at the required ratio over a meaningful part of its background; then fix.attribute=\"style\" and fix.value = a 6-digit hex text colour that meets the ratio against the background's dominant colour. \"pass\" when the text clearly meets the ratio everywhere the glyphs sit.\n- Link purpose (2.4.4): the context is the link text plus its sentence, paragraph, list item or table cell (nearbyText). \"pass\" when the text names the destination page or topic (Home, About, Info, Contact, Pricing, a product or article name), even as a single word in a site menu. \"violation\" only for generic text that names no destination (here, click here, more, read more, learn more, link) when that context does not supply it.\n- Dead in-page link (2.4.1): the item is an <a href=\"#fragment\"> near the top of the page whose fragment matches no id or <a name> (context.engineNote names it). WCAG 2.4.1 is about bypassing repeated blocks. \"violation\" only when the link is the page's bypass mechanism (a skip link that should jump past the header or navigation) and its target is missing, so activating it goes nowhere. Any other dead fragment link is \"pass\" for 2.4.1: an ordinary or script-driven link (tab, accordion, modal or menu trigger, back-to-top).\n- Form with no submit control (3.2.2): the item is a <form> with visible fields and no submit button, input type=submit/image or outside button. \"violation\" only when changing a field or pressing Enter causes an unexpected change of context (navigation, submission, reload) and no submit control exists to ask for it. \"pass\" for a script-driven form, a single search box, or a form whose fields change nothing until the user acts.\n- Decorative image with a title (1.1.1): the item is an <img alt=\"\"> that also has a non-empty title (context.engineNote gives it). \"violation\" when the picture conveys information the title names (a logo, chart, labelled icon, photo of a named subject), so the empty alt hides it. \"pass\" when the picture is purely decorative and the title only repeats nearby text or adds nothing. Do not write alt text for this item.\n- Non-text contrast (1.4.11), motion (2.2.2 / 2.3.3), duplicate ids, ARIA references, captions and everything else: decide from the markup, the context and the crop. For a \"violation\", give fix.attribute/fix.value only when you are certain of the exact value.\n- Be strict: \"pass\" only when confident the finding is not a real barrier.\n\nRespond with ONLY a JSON array, one object per item, same ids:\n[{\"id\": \"...\", \"verdict\": \"pass\" | \"violation\", \"reason\": \"one sentence\", \"fix\": {\"attribute\": \"...\", \"value\": \"...\"}}]\nOmit \"fix\" for passes.";
150
+ interface JudgeChatBody {
151
+ model: string;
152
+ temperature: number;
153
+ max_tokens: number;
154
+ reasoning?: {
155
+ enabled: false;
156
+ };
157
+ messages: Array<{
158
+ role: 'system' | 'user';
159
+ content: unknown;
160
+ }>;
161
+ }
162
+ /**
163
+ * The chat-completions body for one judge batch: the vision model only when the
164
+ * batch is a vision batch AND carries a crop (crops as image_url parts), else the
165
+ * text model. `disableTextModelReasoning`: the default text model reasons unless
166
+ * told not to, and on a real 30-item batch spent all 4096 tokens reasoning (no
167
+ * verdicts, 48 s); set it only for endpoints that accept the `reasoning` field.
168
+ */
169
+ declare function buildJudgeChatBody(req: JudgeRequest, cfg?: {
170
+ visionModel?: string;
171
+ textModel?: string;
172
+ disableTextModelReasoning?: boolean;
173
+ }): JudgeChatBody;
174
+ /** OpenAI-compatible chat completions (OpenRouter). Vision → image_url parts; text-only → text model. */
175
+ declare function openAiCompatibleTransport(cfg: {
176
+ apiKey: string;
177
+ baseUrl?: string;
178
+ visionModel?: string;
179
+ textModel?: string;
180
+ }): JudgeTransport;
181
+ /**
182
+ * Hosted path: the platform judges on the server. Request body = JudgeRequest;
183
+ * response = `{ verdicts: JudgeVerdict[] }`. Any non-2xx → items stay dropped.
184
+ * The platform answers only a user bearer or the hosted MCP's `x-mcp-internal`.
185
+ */
186
+ declare function hostedJudgeTransport(cfg: {
187
+ apiUrl: string;
188
+ token?: string;
189
+ internalSecret?: string;
190
+ }): JudgeTransport;
191
+ declare function judgeUndecided(page: PageLike | null, undecided: readonly ScanIssue[], opts: JudgeRunOptions): Promise<JudgeOutcome>;
192
+
193
+ /**
194
+ * @webability/core — type definitions
195
+ */
196
+ /**
197
+ * Minimal page interface satisfied by both Playwright and Puppeteer.
198
+ * No runtime wrapper needed — both libraries implement these methods natively.
199
+ */
200
+ interface PageLike {
201
+ evaluate<R>(pageFunction: string | ((...args: any[]) => R | Promise<R>), ...args: any[]): Promise<R>;
202
+ addScriptTag(options: {
203
+ content: string;
204
+ }): Promise<unknown>;
205
+ url(): string;
206
+ /**
207
+ * Real keyboard event dispatch (Playwright & Puppeteer both expose this).
208
+ * Optional so minimal PageLike shims still typecheck. The focusVisible
209
+ * detector uses `keyboard.press('Tab')` to put the browser into keyboard
210
+ * interaction modality before probing focus styles — otherwise
211
+ * `:focus-visible` / keyboard-detection-class patterns (focus-visible
212
+ * polyfill, `.user-is-tabbing` body class) never activate under a bare
213
+ * programmatic `.focus()`, producing mass false positives.
214
+ */
215
+ keyboard?: {
216
+ press(key: string): Promise<void>;
217
+ };
218
+ }
219
+ /** All fix issue types the pipeline can detect and fix. */
220
+ type FixIssueType = 'weak_button_name' | 'weak_link_name' | 'weak_heading' | 'weak_iframe_title' | 'weak_page_title' | 'weak_svg_name' | 'weak_skip_link' | 'missing_button_role' | 'missing_semantic_role' | 'missing_document_language' | 'missing_landmark' | 'svg_missing_name' | 'aria_hidden_focusable' | 'adjacent_redundant_link' | 'contrast_insufficient' | 'non_text_contrast_insufficient' | 'target_too_small' | 'focus_not_visible' | 'empty_link' | 'unlabeled_button' | 'missing_label' | 'decorative_icon' | 'skip_link' | 'aria_live' | 'missing_alt' | 'identify_input_purpose' | 'redundant_role' | 'duplicate_id' | 'new_window_link' | 'missing_fieldset_legend' | 'missing_table_header' | 'missing_table_caption' | 'missing_table_scope' | 'external_link_warning' | 'download_link_warning' | 'broken_anchor' | 'form_missing_submit' | 'decorative_image_with_title' | 'placeholder_link' | 'missing_pattern_title' | 'missing_autocomplete' | 'missing_media_controls' | 'unsafe_autoplay' | 'zoom_restriction' | 'missing_button_type' | 'link_consistency' | 'keyboard_trap' | 'focus_order_disruption' | 'missing_captions' | 'color_only_link' | 'label_in_name' | 'onfocus_context_change' | 'focus_obscured_by_sticky' | 'text_spacing_blocked' | 'nested_landmark' | 'motion_uncontrollable' | 'auto_refresh' | 'motion_without_reduced_motion' | 'flashing_content' | 'animated_gif_no_pause' | 'missing_required_indicator' | 'background_image_unlabeled' | 'empty_heading' | 'javascript_link' | 'broken_aria_reference' | 'main_content_hidden' | 'scrollable_not_focusable' | 'meaningful_sequence' | 'sensory_characteristic' | 'image_of_text' | 'reflow_overflow' | 'missing_audio_description' | 'missing_transcript' | 'character_key_shortcut' | 'on_input_change' | 'pointer_gesture' | 'pointer_cancellation' | 'motion_actuation' | 'error_prevention_missing' | 'inconsistent_navigation';
221
+ /** WCAG criterion for each issue type. */
222
+ declare const WCAG_BY_ISSUE: Record<FixIssueType, string>;
223
+ /** A single accessibility issue detected on the page. */
224
+ interface DetectedIssue {
225
+ /** Unique CSS selector targeting the element. */
226
+ selector: string;
227
+ /** HTML/ARIA attribute to set (alt, aria-label, role, tabindex, etc.) */
228
+ attribute: string;
229
+ /** What the element currently has (empty string if missing). */
230
+ currentValue: string;
231
+ /** Classification of the issue. */
232
+ issueType: FixIssueType;
233
+ /** WCAG success criterion reference. */
234
+ wcag: string;
235
+ /** Contextual info for Claude: nearby heading, parent tag, href, page section. */
236
+ context: string;
237
+ /** true = needs Claude to generate the fix value; false = rule already knows it. */
238
+ needsAI: boolean;
239
+ /** When needsAI is false, this is the known fix value. */
240
+ fixValue?: string;
241
+ /** Optional: image src for alt text generation via vision. */
242
+ imageSrc?: string;
243
+ /** Optional: outer HTML snippet (first 300 chars) for Claude context. */
244
+ outerHtml?: string;
245
+ /**
246
+ * Detector-side signal that the VERDICT is uncertain (e.g. contrast against
247
+ * a gradient). The scanner never reports such a finding: it is resolved to a
248
+ * verdict by an engine resolver or dropped. For "verdict certain, fix not
249
+ * mechanical" use `manualFix`.
250
+ */
251
+ needsReview?: boolean;
252
+ /**
253
+ * The verdict is certain but the suggested fix cannot be applied
254
+ * mechanically. Sets `fix.needsManualReview`; never changes the verdict.
255
+ */
256
+ manualFix?: boolean;
257
+ /** Why this finding needs review — surfaced to MCP/SDK consumers. */
258
+ reviewReason?: string;
259
+ /**
260
+ * Optional exact user-facing message. Use when the generic
261
+ * attribute/currentValue template ("missing X", "X is \"Y\"") would state
262
+ * something false or fabricated — e.g. a weak button name whose name comes
263
+ * from text content has no missing aria-label to report (persona round 3,
264
+ * P069: 'weak button name: missing aria-label' on a named control).
265
+ * formatMessage prefers this verbatim when present.
266
+ */
267
+ message?: string;
268
+ }
269
+ /** Every fix detector must implement this interface. */
270
+ interface FixDetector {
271
+ name: string;
272
+ detect(page: PageLike): Promise<DetectedIssue[]>;
273
+ /** Per-page emission cap, when the detector has one. When a run returns
274
+ * >= cap findings the results are TRUNCATED (DOM-order biased) and the
275
+ * scan reports it via ScanResult.truncated — silent truncation reads as
276
+ * "covered everything" when it didn't (loop iter8 / honesty infra). */
277
+ cap?: number;
278
+ }
279
+ /**
280
+ * Browser-runtime detector signature.
281
+ *
282
+ * Each detector exports a pure, zero-argument browser function that uses the
283
+ * `document` global directly and returns DetectedIssue[]. No Puppeteer, no
284
+ * Node imports, no closures over module-scope variables — so it both:
285
+ * 1. Survives serialization via `page.evaluate(fn)` on the server (puppeteer
286
+ * stringifies and re-parses the function inside the browser context).
287
+ * 2. Bundles directly into the widget via webpack and runs against the
288
+ * live customer DOM.
289
+ *
290
+ * Single source of truth — same rules run in both runtimes.
291
+ */
292
+ type BrowserDetector = () => DetectedIssue[];
293
+ interface SiteTheme {
294
+ cssVars: Record<string, string>;
295
+ topBgColors: Array<{
296
+ color: string;
297
+ count: number;
298
+ }>;
299
+ topTextColors: Array<{
300
+ color: string;
301
+ count: number;
302
+ }>;
303
+ }
304
+ type IssueSource = 'webability' | 'axe-core' | 'deep-analysis';
305
+ type IssueImpact = 'critical' | 'serious' | 'moderate' | 'minor';
306
+ type WcagLevel = 'A' | 'AA' | 'AAA';
307
+ /**
308
+ * Internal confidence marker. `needs_review` means an engine could not decide
309
+ * (text on a gradient, marketing imagery that may be a logo, …). Such findings
310
+ * never reach the result: the pixel/caption resolvers and the AI judge turn
311
+ * them into a verdict, or they are dropped. No reported issue carries it.
312
+ */
313
+ type IssueConfidence = 'high' | 'needs_review';
314
+ interface ScanIssue {
315
+ id: string;
316
+ source: IssueSource;
317
+ type: FixIssueType | string;
318
+ wcag: string;
319
+ level: WcagLevel;
320
+ impact: IssueImpact;
321
+ message: string;
322
+ selector: string;
323
+ html?: string;
324
+ /** Internal: set by an engine that could not decide; stripped before the result. */
325
+ confidence?: IssueConfidence;
326
+ /** Internal: why the engine could not decide; handed to the judge as context, never reported. */
327
+ reviewReason?: string;
328
+ fix?: {
329
+ attribute: string;
330
+ currentValue: string;
331
+ suggestedValue?: string;
332
+ needsManualReview: boolean;
333
+ };
334
+ }
335
+ /**
336
+ * Signal that the scan never reached the intended page — the target served a
337
+ * bot-challenge / interstitial (Cloudflare "Just a moment…", "Access Denied",
338
+ * a CAPTCHA wall) or the main document returned an HTTP error status. When
339
+ * present, `issues`/`incomplete` are empty and MUST NOT be read as "the page is
340
+ * clean": nothing real was scanned. Lets a caller distinguish "scanned and
341
+ * clean" from "was never actually scanned".
342
+ */
343
+ interface BlockedInfo {
344
+ /** Human-readable explanation of why the page could not be scanned. */
345
+ reason: string;
346
+ /** Challenge vendor when identifiable (e.g. 'Cloudflare', 'PerimeterX/HUMAN'). */
347
+ vendor?: string;
348
+ /** HTTP status of the main document response, when known. */
349
+ status?: number;
350
+ /** The specific signal that matched (page title, marker selector, or status). */
351
+ signal?: string;
352
+ }
353
+ /** One judged finding — see `ScanResult.judged`. */
354
+ interface JudgedEntry {
355
+ id: string;
356
+ type: string;
357
+ verdict: 'pass' | 'violation';
358
+ reason: string;
359
+ }
360
+ /**
361
+ * A finding or a page change the scanner's own tooling caused (invariant P).
362
+ * Gate metric, bar = 0: any entry means the scan measured our state, not the page.
363
+ * - state-not-restored: a tool left focus / focus-visible / scroll / selection /
364
+ * injected nodes changed (the scanner restored it)
365
+ * - finding-dropped: a finding sat on an injected (marked) node or on an
366
+ * element a tool left focused; it was not reported
367
+ */
368
+ interface InternalBug {
369
+ tool: string;
370
+ kind: 'state-not-restored' | 'finding-dropped';
371
+ detail: string;
372
+ selector?: string;
373
+ /**
374
+ * Diagnosis only: what the page DOM did inside this tool's window (the
375
+ * mutation-log slice). Pages mutate themselves, so this is never used to
376
+ * attribute a finding; it is there so a bug is traceable. Capped.
377
+ */
378
+ mutations?: Array<{
379
+ type: string;
380
+ attr?: string | null;
381
+ target: string;
382
+ added: string[];
383
+ }>;
384
+ }
385
+ interface ScanResult {
386
+ url: string;
387
+ timestamp: string;
388
+ /**
389
+ * Set when the target served a bot-challenge/interstitial or an HTTP error
390
+ * instead of the real page. When present, treat `issues`/`incomplete` as
391
+ * "not scanned" (they are empty), NOT as "no problems found".
392
+ */
393
+ blocked?: BlockedInfo;
394
+ /** High-confidence violations. Safe to surface as bugs. */
395
+ issues: ScanIssue[];
396
+ /** Detectors that hit their per-page cap this run — their counts are lower
397
+ * bounds, not a census. Additive field; absent = nothing truncated. */
398
+ truncated?: Array<{
399
+ detector: string;
400
+ cap: number;
401
+ }>;
402
+ /**
403
+ * Non-fatal engine subsystem failures that silently disable a category of
404
+ * checks. Consumers MUST surface these to the user — a result with
405
+ * `engineWarnings` is NOT a full scan. Currently: `'color:kernel-injection-failed'`
406
+ * disables all contrast checks for the page. Absent = all subsystems healthy.
407
+ */
408
+ engineWarnings?: string[];
409
+ /**
410
+ * Undecided findings dropped with no verdict: no resolver measurement, no
411
+ * model-free rule and no judge verdict (judge off, failed or silent). For
412
+ * these criteria an absent issue is NOT evidence of a pass. Absent = every
413
+ * undecided finding within the level got a verdict.
414
+ */
415
+ unsettled?: {
416
+ count: number;
417
+ wcag: string[];
418
+ };
419
+ /** Invariant P: findings / page changes caused by our own tooling. Always present; `[]` = clean. */
420
+ internal_bugs?: InternalBug[];
421
+ /** Engine (core package) version that produced this result — provenance */
422
+ engine?: string;
423
+ /**
424
+ * @deprecated Always `[]`. The scanner judges uncertain findings itself:
425
+ * each becomes a verdict in `issues` or is dropped. The field stays only so
426
+ * existing consumers that read it keep compiling.
427
+ */
428
+ incomplete: ScanIssue[];
429
+ /**
430
+ * Verdicts on findings the engines could not decide: model-free rules (a
431
+ * measured contrast pass, a decorative or 1x1 image, a reduced-motion rule)
432
+ * and, when configured, the AI judge. `pass` = cleared with a reason,
433
+ * `violation` = promoted into `issues`. Absent when nothing got a verdict.
434
+ */
435
+ judged?: JudgedEntry[];
436
+ summary: {
437
+ total: number;
438
+ critical: number;
439
+ serious: number;
440
+ moderate: number;
441
+ minor: number;
442
+ /** @deprecated Always 0 — there is no incomplete bucket. */
443
+ incomplete: number;
444
+ };
445
+ axeResults?: unknown;
446
+ }
447
+ type ViewportPreset = 'mobile' | 'tablet' | 'desktop';
448
+ declare const VIEWPORT_SIZES: Record<ViewportPreset, {
449
+ width: number;
450
+ height: number;
451
+ }>;
452
+ interface ScanOptions {
453
+ wcagTags?: string[];
454
+ /** @internal test seam: extra detectors run serially, each in its own state guard. */
455
+ extraSerialDetectors?: FixDetector[];
456
+ /**
457
+ * Highest WCAG conformance level to report. Default 'AA': findings whose
458
+ * criteria are all Level AAA are not reported anywhere (issues, incomplete,
459
+ * summary). 'AAA' opts in (and adds axe's wcag2aaa rules); 'A' keeps Level A only.
460
+ */
461
+ level?: WcagLevel;
462
+ /**
463
+ * Run axe-core (default true). axe is the only engine for the checks it
464
+ * covers on the ACT test suite: form-field / select / combobox names and
465
+ * valid ARIA roles have no WebAbility detector. false = detector-only scan
466
+ * without those checks.
467
+ */
468
+ includeAxe?: boolean;
469
+ deep?: boolean;
470
+ deepApiUrl?: string;
471
+ deepApiKey?: string;
472
+ dismissModals?: boolean;
473
+ viewport?: ViewportPreset | {
474
+ width: number;
475
+ height: number;
476
+ };
477
+ browser?: {
478
+ headless?: boolean;
479
+ timeout?: number;
480
+ };
481
+ review?: boolean;
482
+ /** Limit scan to elements within this CSS selector. */
483
+ rootSelector?: string;
484
+ /** Include annotated screenshots of violations in results. */
485
+ enableScreenshots?: boolean;
486
+ /** Scan content inside iframes. Default: false. */
487
+ includeIframes?: boolean;
488
+ /**
489
+ * Wait for entrance animations to finish before scanning (prevents false
490
+ * contrast failures on mid-fade text). `false` disables, a number sets the
491
+ * wait cap in ms. Default: enabled with a 4000ms cap.
492
+ */
493
+ settle?: boolean | number;
494
+ /**
495
+ * HTTP status of the main-document response, for the Page-object call shape
496
+ * where the CALLER navigated (e.g. mcp's flow_scan does its own `page.goto`).
497
+ * The URL-string path captures this itself; on the Page path core cannot see
498
+ * the response, so a plain 4xx/5xx error page with no challenge DOM markers
499
+ * would otherwise be scanned as a clean page. Pass the caller's
500
+ * `response.status()` here so bot-challenge/error-page detection works
501
+ * uniformly across both entrypoints. Ignored on the URL-string path.
502
+ */
503
+ mainStatus?: number;
504
+ /**
505
+ * AI judge for findings the engines could not decide. With a model key
506
+ * (local, OpenRouter-compatible) or `apiUrl` (hosted), each such finding
507
+ * becomes a verdict: a violation with a fix in `issues`, or a recorded pass
508
+ * in `judged`. Unset / empty = no model calls: only the model-free verdicts
509
+ * run, and findings they cannot settle are dropped (listed in `unsettled`).
510
+ */
511
+ judge?: JudgeOptions;
512
+ }
513
+
514
+ export { type BrowserDetector as B, type DetectedIssue as D, type FixDetector as F, type InternalBug as I, JUDGE_DEFAULT_TIMEOUT_MS as J, type PageLike as P, type ScanOptions as S, VIEWPORT_SIZES as V, type WcagLevel as W, type ScanResult as a, type SiteTheme as b, type ScanIssue as c, DEFAULT_JUDGE_BASE_URL as d, DEFAULT_JUDGE_TEXT_MODEL as e, DEFAULT_JUDGE_VISION_MODEL as f, type FixIssueType as g, type IssueImpact as h, type IssueSource as i, JUDGE_SYSTEM_PROMPT as j, type JudgeChatBody as k, type JudgeItem as l, type JudgeOptions as m, type JudgeRequest as n, type JudgeTransport as o, type JudgeVerdict as p, type JudgedEntry as q, type ViewportPreset as r, WCAG_BY_ISSUE as s, buildJudgeChatBody as t, hostedJudgeTransport as u, judgeUndecided as v, openAiCompatibleTransport as w, resetJudgeCache as x };