scenescout 3.15.0 → 3.16.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/CHANGELOG.md +75 -0
- package/README.md +64 -15
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +190 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +104 -43
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +182 -39
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +4 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +567 -57
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +49 -16
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a crawl makes of one route it loaded: whether an explicitly requested
|
|
3
|
+
* path joins the route contract, and the summary line it prints. Kept out of
|
|
4
|
+
* browser.ts so both rules can be table-tested without a browser.
|
|
5
|
+
*/
|
|
6
|
+
import { isNonPageRoute } from "./fingerprint.js";
|
|
7
|
+
/** The crawl flag for a main area showing only its state, or null. */
|
|
8
|
+
export function mainStateFlag(state) {
|
|
9
|
+
return state === "error" ? "ERROR-VIEW" : state === "loading" ? "STILL-LOADING" : null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The route an explicitly requested crawl path adds to the route contract,
|
|
13
|
+
* or null. A path someone asked for, that answered with a page and stayed
|
|
14
|
+
* where it was asked, is a route of the app whether or not any link names
|
|
15
|
+
* it: left out, a sweep of forty extra paths left "Routes visited: 37/37"
|
|
16
|
+
* unchanged and the report understated what was checked. A path that
|
|
17
|
+
* redirected is not added (the page it landed on is the route that exists),
|
|
18
|
+
* nor one that failed, nor a dead end (an app that answers 200 for every
|
|
19
|
+
* path), nor one whose main area showed only an error view or a loading
|
|
20
|
+
* placeholder (a client-rendered app answers 200 and draws its not-found
|
|
21
|
+
* view), nor an API or download path. Added routes are remembered across
|
|
22
|
+
* runs, so a probe of made-up paths must not become part of the contract.
|
|
23
|
+
*/
|
|
24
|
+
export function crawledRoute(o) {
|
|
25
|
+
if (typeof o.status !== "number" || o.status >= 400 || o.deadEnd || o.mainState)
|
|
26
|
+
return null;
|
|
27
|
+
if (o.loginRedirect || o.landedRoute !== o.requestedRoute)
|
|
28
|
+
return null;
|
|
29
|
+
if (isNonPageRoute(o.requestedRoute))
|
|
30
|
+
return null;
|
|
31
|
+
return o.requestedRoute;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The crawl's one line for a route. A path that ended on another route says
|
|
35
|
+
* so, as scout_navigate does: "/old — 200 · 94 el" read as the old page
|
|
36
|
+
* answering, when the 200 and the 94 elements were the page it landed on.
|
|
37
|
+
* The sign-in bounce has its own flag (AUTH-REDIRECT) and is not repeated.
|
|
38
|
+
* `main` is what the page's main area holds (collector mainRegionTag), so
|
|
39
|
+
* "41 el" alone cannot hide a main area that rendered nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function crawlLine(o, counts, flags) {
|
|
42
|
+
const redirected = !o.loginRedirect && o.landedRoute !== o.requestedRoute;
|
|
43
|
+
const allFlags = [...(redirected ? [`REDIRECTED → ${o.landedRoute}`] : []), ...flags];
|
|
44
|
+
return (`${o.path} — ${o.status} · ${counts.elements} el` +
|
|
45
|
+
(counts.main ? ` · ${counts.main}` : "") +
|
|
46
|
+
(counts.missingTestid ? ` · ${counts.missingTestid} no-testid` : "") +
|
|
47
|
+
(counts.unnamed ? ` · ${counts.unnamed} unnamed` : "") +
|
|
48
|
+
(allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
|
|
49
|
+
}
|
package/dist/engine/design.js
CHANGED
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
* grays, a focused accent palette, one elevation system, consistent
|
|
22
22
|
* controls, visible keyboard focus.
|
|
23
23
|
*/
|
|
24
|
+
import { missingName, NAME_SRC, placeholderOnly } from "./collector.js";
|
|
25
|
+
/**
|
|
26
|
+
* Whether some text names a filter or a facet, as page-side source: one of its
|
|
27
|
+
* words, split at camelCase and punctuation, is filter(s) or facet(s). Whole
|
|
28
|
+
* words only, so "filterable-list" or "unfiltered" says nothing.
|
|
29
|
+
*/
|
|
30
|
+
export const FILTER_WORDS_SRC = `(s) => (s || "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).some((w) => w === "filter" || w === "filters" || w === "facet" || w === "facets")`;
|
|
31
|
+
/** FILTER_WORDS_SRC run outside a page, from the same source, so a table test exercises the rule the collector ships. */
|
|
32
|
+
export function namesFilter(text) {
|
|
33
|
+
return new Function(`return (${FILTER_WORDS_SRC});`)()(text);
|
|
34
|
+
}
|
|
24
35
|
/**
|
|
25
36
|
* In-page collector, shipped as a string expression (immune to loader
|
|
26
37
|
* transforms — same rationale as the interactables collector).
|
|
@@ -79,6 +90,20 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
79
90
|
}
|
|
80
91
|
return false;
|
|
81
92
|
};
|
|
93
|
+
const nameOf = ${NAME_SRC};
|
|
94
|
+
const namesFilter = ${FILTER_WORDS_SRC};
|
|
95
|
+
// A filter panel names itself one somewhere on the way up: its test id, id,
|
|
96
|
+
// label, the element its aria-labelledby points at, or a fieldset's legend.
|
|
97
|
+
const inFilterOf = (el) => {
|
|
98
|
+
for (let n = el, i = 0; n && n !== document.body && i < 15; n = n.parentElement, i++) {
|
|
99
|
+
const legend = n.tagName === "FIELDSET" ? n.querySelector(":scope > legend") : null;
|
|
100
|
+
const labelledBy = (n.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean)
|
|
101
|
+
.map((id) => { const t = document.getElementById(id); return t ? t.textContent || "" : ""; }).join(" ");
|
|
102
|
+
const said = [n.getAttribute("data-testid"), n.id, n.getAttribute("aria-label"), labelledBy, legend ? legend.textContent : ""].join(" ");
|
|
103
|
+
if (namesFilter(said)) return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
82
107
|
const interactiveSel = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [onclick]';
|
|
83
108
|
// The app shell, as the markup declares it. A <header>/<footer> inside a
|
|
84
109
|
// <section> is that section's own heading block, not the page banner (the
|
|
@@ -108,6 +133,33 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
108
133
|
else h = (r - g) / (max - min) + 4;
|
|
109
134
|
return ((h * 60) + 360) % 360;
|
|
110
135
|
};
|
|
136
|
+
// The box a pointer user hits to operate a native input (StyleRecord.target).
|
|
137
|
+
const unionBox = (boxes) => {
|
|
138
|
+
let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
|
|
139
|
+
for (const x of boxes) { l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom); }
|
|
140
|
+
return { x: Math.round(l), y: Math.round(t), w: Math.round(r - l), h: Math.round(b - t) };
|
|
141
|
+
};
|
|
142
|
+
const gapBetween = (a, b) => Math.max(a.left - b.right, b.left - a.right, a.top - b.bottom, b.top - a.bottom, 0);
|
|
143
|
+
const targetOf = (el, rect, hiddenInput) => {
|
|
144
|
+
if (el.tagName !== "INPUT") return null;
|
|
145
|
+
const boxes = [];
|
|
146
|
+
for (const label of Array.from(el.labels || [])) {
|
|
147
|
+
const lr = label.getBoundingClientRect();
|
|
148
|
+
if (lr.width === 0 || lr.height === 0) continue;
|
|
149
|
+
// A label elsewhere on the page also operates the input, but it is a
|
|
150
|
+
// second target, not a bigger one; only a label that wraps or touches
|
|
151
|
+
// the input makes one box with it. A hidden input has no box of its own,
|
|
152
|
+
// so any label of it is the target.
|
|
153
|
+
if (hiddenInput || label.contains(el) || gapBetween(lr, rect) <= 4) boxes.push(lr);
|
|
154
|
+
}
|
|
155
|
+
if (hiddenInput && boxes.length === 0 && el.parentElement) {
|
|
156
|
+
const zone = el.parentElement.closest('button, [role="button"], [onclick], [tabindex]:not([tabindex="-1"])');
|
|
157
|
+
if (zone) boxes.push(zone.getBoundingClientRect());
|
|
158
|
+
}
|
|
159
|
+
if (boxes.length === 0) return null;
|
|
160
|
+
if (!hiddenInput) boxes.push(rect);
|
|
161
|
+
return unionBox(boxes);
|
|
162
|
+
};
|
|
111
163
|
const vh = window.innerHeight;
|
|
112
164
|
let density = 0;
|
|
113
165
|
const all = document.querySelectorAll("body *");
|
|
@@ -155,6 +207,8 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
155
207
|
const slop = sideStripe || gradientText || glass || glow || aiGradient;
|
|
156
208
|
if (!interactive && !ownText && !slop) continue;
|
|
157
209
|
const fullText = ownText ? (el.textContent || "").trim().replace(/\\s+/g, " ") : "";
|
|
210
|
+
const target = interactive ? targetOf(el, rect, srOnly || s.opacity === "0") : null;
|
|
211
|
+
const named = interactive ? nameOf(el) : null;
|
|
158
212
|
out.push({
|
|
159
213
|
tag: el.tagName.toLowerCase(),
|
|
160
214
|
testid: el.getAttribute("data-testid"),
|
|
@@ -174,14 +228,16 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
174
228
|
padding: [px(s.paddingTop), px(s.paddingRight), px(s.paddingBottom), px(s.paddingLeft)],
|
|
175
229
|
marginV: [px(s.marginTop), px(s.marginBottom)],
|
|
176
230
|
radius: px(s.borderTopLeftRadius),
|
|
177
|
-
shadow: s.boxShadow && s.boxShadow !== "none" ? s.boxShadow.replace(/\\s+/g, " ").slice(0,
|
|
231
|
+
shadow: s.boxShadow && s.boxShadow !== "none" ? s.boxShadow.replace(/\\s+/g, " ").slice(0, 400) : "",
|
|
178
232
|
// srOnly text is DELIBERATELY a 1px box with overflow hidden — the exact
|
|
179
233
|
// signature of "text wider than its box". Flagging it reported the
|
|
180
234
|
// accessibility affordance itself as an accessibility defect.
|
|
181
235
|
clipped: !srOnly && el.scrollWidth > el.clientWidth + 2 && /hidden|clip/.test(s.overflowX) && s.textOverflow !== "ellipsis" && ownText,
|
|
236
|
+
...(target ? { target } : {}),
|
|
182
237
|
fixed: s.position === "fixed" || s.position === "sticky",
|
|
183
238
|
required: el.hasAttribute("required") || el.getAttribute("aria-required") === "true",
|
|
184
239
|
submitish: el.matches('button[type="submit"], input[type="submit"]') || /\\b(save|submit|create|send|confirm|apply|continue|next|finish|approve|sign)\\b/i.test(fullText),
|
|
240
|
+
...(named ? { name: named.name, nameFrom: named.from, ariaHidden: el.closest('[aria-hidden="true"]') !== null } : {}),
|
|
185
241
|
inputType: el.tagName === "INPUT" ? (el.getAttribute("type") || "text").toLowerCase() : "",
|
|
186
242
|
role: (el.getAttribute("role") || "").toLowerCase(),
|
|
187
243
|
filled: ownFill(s),
|
|
@@ -189,6 +245,7 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
189
245
|
inRow: !!el.closest('tr, [role="row"]'),
|
|
190
246
|
inSearch: !!el.closest('search, [role="search"]'),
|
|
191
247
|
inBreadcrumb: !!el.closest('[aria-label*="breadcrumb" i], [class*="breadcrumb" i]'),
|
|
248
|
+
inFilter: interactive && inFilterOf(el),
|
|
192
249
|
shell: inShell(el),
|
|
193
250
|
sideStripe, gradientText, glass, glow, aiGradient,
|
|
194
251
|
});
|
|
@@ -256,9 +313,20 @@ function hueOf([r, g, b]) {
|
|
|
256
313
|
h = (r - g) / (max - min) + 4;
|
|
257
314
|
return Math.round((h * 60 + 360) % 360);
|
|
258
315
|
}
|
|
259
|
-
|
|
316
|
+
/**
|
|
317
|
+
* How an audit line names an element: its test id, else what a user hears for
|
|
318
|
+
* a control (its accessible name, the snapshot's), else its own text. A name
|
|
319
|
+
* that is only the field's name attribute or type is not one anybody hears,
|
|
320
|
+
* so it gives way to the text.
|
|
321
|
+
*/
|
|
322
|
+
const label = (r) => {
|
|
323
|
+
if (r.testid)
|
|
324
|
+
return `[${r.testid}]`;
|
|
325
|
+
const heard = r.name && r.nameFrom !== "fallback" ? r.name : r.text;
|
|
326
|
+
return `<${r.tag}> "${heard.slice(0, 30) || "(no text)"}"`;
|
|
327
|
+
};
|
|
260
328
|
/** One wording for a small target, page or shell alike: the shell section's, so its prose is unchanged. */
|
|
261
|
-
const tinyTargetDetail = (r) => `${label(r)} — ${Math.round(r.
|
|
329
|
+
const tinyTargetDetail = (r) => `${label(r)} — ${Math.round(targetBox(r).w)}×${Math.round(targetBox(r).h)}px tap target`;
|
|
262
330
|
const clippedTextDetail = (r) => `${label(r)} — text is clipped by its container`;
|
|
263
331
|
/** One wording for body-coloured links, page or shell alike. */
|
|
264
332
|
const indistinctDetail = (bodyColor) => `links with no underline in the body-text colour ${bodyColor}`;
|
|
@@ -309,8 +377,16 @@ function gridValues(top) {
|
|
|
309
377
|
.map((v) => `${v}px`)
|
|
310
378
|
.join(", ");
|
|
311
379
|
}
|
|
380
|
+
/** The box a pointer user hits to operate the control: its label or drop zone where one operates it, else its own. */
|
|
381
|
+
export function targetBox(r) {
|
|
382
|
+
return r.target ?? r.rect;
|
|
383
|
+
}
|
|
384
|
+
/** The WCAG 2.2 target-size minimum, in CSS pixels. */
|
|
385
|
+
const MIN_TARGET = 24;
|
|
312
386
|
/** Inputs that are buttons, not fields. */
|
|
313
387
|
const BUTTON_INPUT_TYPES = new Set(["submit", "button", "reset", "image"]);
|
|
388
|
+
/** Inputs a user types into. Every other type (a checkbox, radio, range, colour or file) is picked rather than typed, and outside a form most of those apply at once. */
|
|
389
|
+
const TYPED_INPUT_TYPES = new Set(["text", "email", "number", "tel", "url", "password", "date", "datetime-local", "month", "week", "time"]);
|
|
314
390
|
/** A button, or a link painted as one. Fields, selects and plain links never compete as actions. */
|
|
315
391
|
function buttonLike(r) {
|
|
316
392
|
if (r.tag === "button" || r.role === "button")
|
|
@@ -322,10 +398,14 @@ function buttonLike(r) {
|
|
|
322
398
|
/**
|
|
323
399
|
* The fields a user is asked to fill in and submit. Excluded: controls in a
|
|
324
400
|
* table row (a selection checkbox, or a select that edits the row in place),
|
|
325
|
-
* search boxes,
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
401
|
+
* search boxes, controls in a panel that names itself a filter, and inputs
|
|
402
|
+
* that are buttons. When some fields sit in a <form>, only those count: the
|
|
403
|
+
* rest of the page (filters, toolbars) is not part of what gets submitted. A
|
|
404
|
+
* page with no <form> at all is judged on the fields a user types into,
|
|
405
|
+
* because many apps build their forms without the element; its selects and
|
|
406
|
+
* every input not in TYPED_INPUT_TYPES (checkboxes, radios, file pickers) are
|
|
407
|
+
* left out, since outside a form those are most often filters and settings
|
|
408
|
+
* that apply as they change.
|
|
329
409
|
*/
|
|
330
410
|
function formFields(records) {
|
|
331
411
|
const candidates = records.filter((r) => r.interactive &&
|
|
@@ -333,13 +413,166 @@ function formFields(records) {
|
|
|
333
413
|
!BUTTON_INPUT_TYPES.has(r.inputType) &&
|
|
334
414
|
r.inputType !== "search" &&
|
|
335
415
|
!r.inSearch &&
|
|
336
|
-
!r.inRow
|
|
416
|
+
!r.inRow &&
|
|
417
|
+
!r.inFilter);
|
|
337
418
|
const inForm = candidates.filter((r) => r.inForm);
|
|
338
|
-
return inForm.length > 0 ? inForm : candidates;
|
|
419
|
+
return inForm.length > 0 ? inForm : candidates.filter(typedField);
|
|
420
|
+
}
|
|
421
|
+
/** A field a user types into: a text-like input or a textarea. */
|
|
422
|
+
function typedField(r) {
|
|
423
|
+
return r.tag === "textarea" || (r.tag === "input" && TYPED_INPUT_TYPES.has(r.inputType || "text"));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The layers of a computed box-shadow that draw something: a layer with no
|
|
427
|
+
* offset, blur or spread, or a transparent colour, paints nothing. Utility CSS
|
|
428
|
+
* stacks such placeholder layers (empty rings) in front of the real shadow on
|
|
429
|
+
* every element, so a census keyed on the raw value counts the placeholders.
|
|
430
|
+
* Returned in order, whitespace normalized; empty when nothing is drawn.
|
|
431
|
+
*/
|
|
432
|
+
export function shadowLayers(shadow) {
|
|
433
|
+
const layers = [];
|
|
434
|
+
let depth = 0;
|
|
435
|
+
let current = "";
|
|
436
|
+
for (const ch of shadow) {
|
|
437
|
+
if (ch === "(")
|
|
438
|
+
depth += 1;
|
|
439
|
+
if (ch === ")")
|
|
440
|
+
depth = Math.max(0, depth - 1);
|
|
441
|
+
if (ch === "," && depth === 0) {
|
|
442
|
+
layers.push(current);
|
|
443
|
+
current = "";
|
|
444
|
+
}
|
|
445
|
+
else
|
|
446
|
+
current += ch;
|
|
447
|
+
}
|
|
448
|
+
layers.push(current);
|
|
449
|
+
return layers
|
|
450
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
451
|
+
.filter((l) => {
|
|
452
|
+
if (!l || l === "none")
|
|
453
|
+
return false;
|
|
454
|
+
const lengths = (l.match(/-?[0-9.]+px/g) ?? []).map(parseFloat);
|
|
455
|
+
if (lengths.every((n) => n === 0))
|
|
456
|
+
return false;
|
|
457
|
+
if (/\btransparent\b/.test(l))
|
|
458
|
+
return false;
|
|
459
|
+
const colour = l.match(/rgba?\([^)]*\)/);
|
|
460
|
+
return !(colour && parseRgb(colour[0])?.[3] === 0);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/** The elevation a box-shadow draws, as one key: its drawing layers. "" for none. */
|
|
464
|
+
export function elevationKey(shadow) {
|
|
465
|
+
return shadowLayers(shadow).join(", ");
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The layer that tells one elevation from the others listed beside it: its
|
|
469
|
+
* first layer that not every other key has. Shown in place of the whole value,
|
|
470
|
+
* whose differences often begin past any width an example can be given.
|
|
471
|
+
*/
|
|
472
|
+
export function distinguishingLayer(key, others) {
|
|
473
|
+
const layers = shadowLayers(key);
|
|
474
|
+
const rest = others.filter((o) => o !== key).map(shadowLayers);
|
|
475
|
+
return layers.find((l) => rest.some((o) => !o.includes(l))) ?? layers[0] ?? "";
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* A colour with no hue family is a gray: neutral or tinted (a slate or a warm
|
|
479
|
+
* stone), the shades text and surfaces are built from. Near-white and
|
|
480
|
+
* near-black are left out, as before: every page has them.
|
|
481
|
+
*/
|
|
482
|
+
export function grayOf(rgb) {
|
|
483
|
+
const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
484
|
+
return hueOf(rgb) === null && avg > 20 && avg < 245;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Grays counted as the steps of a scale: colours within GRAY_STEP of one in
|
|
488
|
+
* every channel are one step (a translucent text colour composited over two
|
|
489
|
+
* backgrounds lands a unit or two apart). Commonest first, each with its use count.
|
|
490
|
+
*/
|
|
491
|
+
export function grayCensus(colours) {
|
|
492
|
+
const freq = new Map();
|
|
493
|
+
for (const c of colours) {
|
|
494
|
+
if (!grayOf(c))
|
|
495
|
+
continue;
|
|
496
|
+
const k = c.join(",");
|
|
497
|
+
const e = freq.get(k) ?? { rgb: c, n: 0 };
|
|
498
|
+
e.n += 1;
|
|
499
|
+
freq.set(k, e);
|
|
500
|
+
}
|
|
501
|
+
const steps = [];
|
|
502
|
+
for (const e of [...freq.values()].sort((a, b) => b.n - a.n)) {
|
|
503
|
+
const near = steps.find((s) => s.rgb.every((v, i) => Math.abs(v - e.rgb[i]) <= GRAY_STEP));
|
|
504
|
+
if (near)
|
|
505
|
+
near.n += e.n;
|
|
506
|
+
else
|
|
507
|
+
steps.push({ ...e });
|
|
508
|
+
}
|
|
509
|
+
return steps.sort((a, b) => b.n - a.n);
|
|
510
|
+
}
|
|
511
|
+
/** How far apart, per channel, two grays may be and still be one step of a scale. */
|
|
512
|
+
const GRAY_STEP = 4;
|
|
513
|
+
/**
|
|
514
|
+
* The page's controls a screen-reader user cannot tell apart: fields and
|
|
515
|
+
* controls with no name, and fields whose only label is the placeholder. The
|
|
516
|
+
* snapshot's own predicates (collector.ts), over the snapshot's own name, so
|
|
517
|
+
* the audit and the snapshot agree on a page. Records with no name read are
|
|
518
|
+
* left out rather than guessed.
|
|
519
|
+
*/
|
|
520
|
+
export function unnamedControls(records) {
|
|
521
|
+
const named = records.filter((r) => r.interactive && typeof r.name === "string");
|
|
522
|
+
const asSnapshot = (r) => ({
|
|
523
|
+
role: r.role,
|
|
524
|
+
name: r.name ?? "",
|
|
525
|
+
nameFrom: r.nameFrom ?? null,
|
|
526
|
+
interactive: true,
|
|
527
|
+
ariaHidden: r.ariaHidden === true,
|
|
528
|
+
});
|
|
529
|
+
return {
|
|
530
|
+
unnamed: named.filter((r) => missingName(asSnapshot(r))),
|
|
531
|
+
placeholder: named.filter((r) => placeholderOnly(asSnapshot(r))),
|
|
532
|
+
};
|
|
339
533
|
}
|
|
340
534
|
/** Below the WCAG 2.2 target-size minimum; inline links are exempt by that rule. */
|
|
341
|
-
function
|
|
342
|
-
|
|
535
|
+
function undersized(r) {
|
|
536
|
+
const b = targetBox(r);
|
|
537
|
+
return r.interactive && r.tag !== "a" && (b.h < MIN_TARGET || b.w < MIN_TARGET) && b.h > 0;
|
|
538
|
+
}
|
|
539
|
+
const centreOf = (b) => [b.x + b.w / 2, b.y + b.h / 2];
|
|
540
|
+
/** Distance from a point to the nearest edge of a box; 0 inside it. */
|
|
541
|
+
function distanceToBox([px, py], b) {
|
|
542
|
+
const dx = Math.max(b.x - px, 0, px - (b.x + b.w));
|
|
543
|
+
const dy = Math.max(b.y - py, 0, py - (b.y + b.h));
|
|
544
|
+
return Math.hypot(dx, dy);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The WCAG 2.5.8 spacing exception: an undersized target passes when a 24px
|
|
548
|
+
* circle on its centre touches no other target and no other undersized
|
|
549
|
+
* target's circle. `others` is every interactive record on the page, shell
|
|
550
|
+
* included, since a neighbour in the shell is as easy to hit by mistake.
|
|
551
|
+
*/
|
|
552
|
+
export function spacedApart(r, others) {
|
|
553
|
+
const c = centreOf(targetBox(r));
|
|
554
|
+
for (const o of others) {
|
|
555
|
+
if (o === r || !o.interactive)
|
|
556
|
+
continue;
|
|
557
|
+
const ob = targetBox(o);
|
|
558
|
+
if (ob.w === 0 || ob.h === 0)
|
|
559
|
+
continue;
|
|
560
|
+
if (undersized(o)) {
|
|
561
|
+
const [ox, oy] = centreOf(ob);
|
|
562
|
+
if (Math.hypot(ox - c[0], oy - c[1]) < MIN_TARGET)
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
else if (distanceToBox(c, ob) < MIN_TARGET / 2)
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* A target that fails WCAG 2.5.8: under 24px, measured on what the user hits
|
|
572
|
+
* (targetBox), and not saved by the spacing exception.
|
|
573
|
+
*/
|
|
574
|
+
export function tooSmall(r, all) {
|
|
575
|
+
return undersized(r) && !spacedApart(r, all);
|
|
343
576
|
}
|
|
344
577
|
/**
|
|
345
578
|
* Reduce raw style records + page facts to a judgeable design digest + score.
|
|
@@ -451,14 +684,16 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
451
684
|
spacingLines.push(`${mar.pct}% of vertical margins are off a 4px grid — ad-hoc values: ${mar.top.map(([v, n]) => `${v}px×${n}`).join(", ")}`);
|
|
452
685
|
if (spacingLines.length > 0)
|
|
453
686
|
sections.push(`SPACING: ` + spacingLines.join("\n "));
|
|
454
|
-
// ---- 5. Touch/click targets below ~24px are hard to hit
|
|
455
|
-
//
|
|
456
|
-
|
|
687
|
+
// ---- 5. Touch/click targets below ~24px are hard to hit (WCAG 2.5.8). Measured
|
|
688
|
+
// on what the user hits (a native input's label or drop zone), with
|
|
689
|
+
// that rule's exceptions: inline links, and a small target with nothing
|
|
690
|
+
// else within its 24px circle. Worst offenders first.
|
|
691
|
+
const tiny = records.filter((r) => tooSmall(r, allRecords)).sort((a, b) => targetBox(a).w * targetBox(a).h - targetBox(b).w * targetBox(b).h);
|
|
457
692
|
if (tiny.length > 0) {
|
|
458
693
|
sections.push(`TINY targets (${tiny.length}, smallest first):\n` +
|
|
459
694
|
tiny
|
|
460
695
|
.slice(0, 6)
|
|
461
|
-
.map((r) => ` ⚠ ${label(r)} — ${r.
|
|
696
|
+
.map((r) => ` ⚠ ${label(r)} — ${targetBox(r).w}×${targetBox(r).h}px`)
|
|
462
697
|
.join("\n"));
|
|
463
698
|
}
|
|
464
699
|
// ---- 6. Clipped text (overflow hidden without ellipsis) — content silently cut off.
|
|
@@ -503,15 +738,16 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
503
738
|
if (buttonHeights.length > 3) {
|
|
504
739
|
consistency.push(`→ buttons render at ${buttonHeights.length} different heights (${buttonHeights.slice(0, 8).join(", ")}px) — 1–2 control sizes read as a system`);
|
|
505
740
|
}
|
|
741
|
+
// Keyed on the layers that draw something (elevationKey); each example shows the layer that sets it apart.
|
|
506
742
|
const shadows = new Map();
|
|
507
|
-
for (const r of records)
|
|
508
|
-
|
|
509
|
-
|
|
743
|
+
for (const r of records) {
|
|
744
|
+
const key = elevationKey(r.shadow);
|
|
745
|
+
if (key)
|
|
746
|
+
shadows.set(key, (shadows.get(key) ?? 0) + 1);
|
|
747
|
+
}
|
|
510
748
|
if (shadows.size > 4) {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
.slice(0, 3)
|
|
514
|
-
.map(([s]) => `"${s.slice(0, 40)}"`);
|
|
749
|
+
const keys = [...shadows.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
750
|
+
const top = keys.slice(0, 3).map((k) => `"${distinguishingLayer(k, keys.slice(0, 3)).slice(0, 60)}"`);
|
|
515
751
|
consistency.push(`→ ${shadows.size} distinct box-shadow styles (${top.join(", ")}…) — an elevation system needs 2–3 levels, not one per component`);
|
|
516
752
|
}
|
|
517
753
|
if (consistency.length > 0)
|
|
@@ -522,20 +758,15 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
522
758
|
if (pureBlack.length > 0) {
|
|
523
759
|
palette.push(`→ pure #000-on-#fff body text (${pureBlack.length} block(s), e.g. ${label(pureBlack[0])}) — near-black (rgb(23,23,23)-ish) reads softer at length`);
|
|
524
760
|
}
|
|
525
|
-
const grayFreq = new Map();
|
|
526
761
|
const hueFamilies = new Map();
|
|
762
|
+
const colours = [];
|
|
527
763
|
for (const r of records) {
|
|
528
764
|
for (const c of [r.color, r.bg]) {
|
|
529
765
|
const p = parseRgb(c);
|
|
530
766
|
if (!p)
|
|
531
767
|
continue;
|
|
532
768
|
const rgb = [p[0], p[1], p[2]];
|
|
533
|
-
|
|
534
|
-
const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
535
|
-
if (spread <= 10 && avg > 20 && avg < 245) {
|
|
536
|
-
const key = rgb.join(",");
|
|
537
|
-
grayFreq.set(key, (grayFreq.get(key) ?? 0) + 1);
|
|
538
|
-
}
|
|
769
|
+
colours.push(rgb);
|
|
539
770
|
const hue = hueOf(rgb);
|
|
540
771
|
if (hue !== null) {
|
|
541
772
|
const bucket = (Math.round(hue / 30) * 30) % 360;
|
|
@@ -543,12 +774,10 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
543
774
|
}
|
|
544
775
|
}
|
|
545
776
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
.map(([k]) => `rgb(${k})`);
|
|
551
|
-
palette.push(`→ ${grayFreq.size} distinct grays (${top.join(", ")}…) — a deliberate gray scale runs 4–6 steps; near-duplicates suggest ad-hoc values`);
|
|
777
|
+
const grays = grayCensus(colours);
|
|
778
|
+
if (grays.length > 6) {
|
|
779
|
+
const top = grays.slice(0, 5).map((g) => `rgb(${g.rgb.join(",")})`);
|
|
780
|
+
palette.push(`→ ${grays.length} distinct grays (${top.join(", ")}…) — a deliberate gray scale runs 4–6 steps; near-duplicates suggest ad-hoc values`);
|
|
552
781
|
}
|
|
553
782
|
if (hueFamilies.size > 5) {
|
|
554
783
|
const hues = [...hueFamilies.keys()].sort((a, b) => a - b);
|
|
@@ -613,6 +842,20 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
613
842
|
}
|
|
614
843
|
if (structure.length > 0)
|
|
615
844
|
sections.push(`STRUCTURE:\n` + structure.map((s) => ` ${s}`).join("\n"));
|
|
845
|
+
// ---- 10b. NAMES: a control a screen reader cannot name, or a field labelled
|
|
846
|
+
// only by a placeholder that disappears as the user types.
|
|
847
|
+
const names = unnamedControls(records);
|
|
848
|
+
const nameLines = [
|
|
849
|
+
...names.unnamed.map((r) => `⚠ ${label(r)} — ${r.tag === "input" || r.tag === "select" || r.tag === "textarea" ? "field" : "control"} with no accessible name`),
|
|
850
|
+
...names.placeholder.map((r) => `⚠ ${label(r)} — labelled only by its placeholder, which is gone once the user types`),
|
|
851
|
+
];
|
|
852
|
+
if (nameLines.length > 0) {
|
|
853
|
+
sections.push(`NAMES (${nameLines.length}):\n` +
|
|
854
|
+
nameLines
|
|
855
|
+
.slice(0, 6)
|
|
856
|
+
.map((s) => ` ${s}`)
|
|
857
|
+
.join("\n"));
|
|
858
|
+
}
|
|
616
859
|
// ---- 11. AFFORDANCES: keyboard focus visibility (trusted-Tab sampled engine-side) + indistinguishable links.
|
|
617
860
|
const affordances = [];
|
|
618
861
|
const focusless = page.focusSamples.filter((f) => !f.indicator);
|
|
@@ -740,13 +983,13 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
740
983
|
const gridPct = pad.n > 0 ? 100 - pad.pct : 100;
|
|
741
984
|
const summary = `SYSTEM SUMMARY: ${sizes.length} font sizes (≤7 healthy) · ${families.length} families (≤2) · ` +
|
|
742
985
|
`${buttonRadii.length} button radii (1–2) · ${buttonHeights.length} button heights (1–3) · ${shadows.size} shadow styles (≤3) · ` +
|
|
743
|
-
`${
|
|
986
|
+
`${grays.length} grays (4–6) · ${hueFamilies.size} accent hue families (1–3 + status) · ${gridPct}% spacing on 4px grid · ` +
|
|
744
987
|
`${page.density} interactive elements in first viewport${page.density > 40 ? " (dense — consider progressive disclosure)" : ""}`;
|
|
745
988
|
// ---- Multi-indicator PAGE SCORE. Deductive: start at 100 per dimension,
|
|
746
989
|
// subtract per measured issue, floor at 0. Weights favour a11y and
|
|
747
990
|
// task clarity — a page can be pretty and still hard to use.
|
|
748
991
|
const floor0 = (n) => Math.max(0, Math.round(n));
|
|
749
|
-
const a11yScore = floor0(100 - contrastFails.length * 4 - focusless.length * 6 - tiny.length * 3);
|
|
992
|
+
const a11yScore = floor0(100 - contrastFails.length * 4 - focusless.length * 6 - tiny.length * 3 - names.unnamed.length * 6 - names.placeholder.length * 4);
|
|
750
993
|
const craftScore = floor0(100 - readability.length * 4 - palette.length * 4 - slopTells.length * 5 - clipped.length * 3 - distorted.length * 4);
|
|
751
994
|
const consistencyScore = floor0(100 -
|
|
752
995
|
consistency.length * 8 -
|
|
@@ -771,7 +1014,7 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
771
1014
|
const chromeSection = [];
|
|
772
1015
|
const chromeDefects = [];
|
|
773
1016
|
if (chromeRecords.length > 0) {
|
|
774
|
-
chromeDefects.push(...contrastFailures(chromeRecords).map((detail) => ({ rule: "contrast", detail, chrome: true })), ...chromeRecords.filter((r) =>
|
|
1017
|
+
chromeDefects.push(...contrastFailures(chromeRecords).map((detail) => ({ rule: "contrast", detail, chrome: true })), ...chromeRecords.filter((r) => tooSmall(r, allRecords)).map((r) => ({ rule: "tiny-target", detail: tinyTargetDetail(r), chrome: true })), ...chromeRecords.filter((r) => r.clipped && r.textLen > 0).map((r) => ({ rule: "clipped-text", detail: clippedTextDetail(r), chrome: true })), ...(chromeIndistinct.length > 0 ? [{ rule: "indistinct-link", detail: indistinctDetail(dominantBody ?? ""), chrome: true }] : []));
|
|
775
1018
|
// A convention (indistinct-link) is a → line, as it is on the page; the rest are measurable defects.
|
|
776
1019
|
const chromeIssues = chromeDefects.map((d) => `${d.rule === "indistinct-link" ? "→" : "⚠"} ${d.detail}`);
|
|
777
1020
|
if (chromeIssues.length > 0) {
|