scenescout 3.14.1 → 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 +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- 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 +198 -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 +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- 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/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- 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 +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
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,7 +90,38 @@ 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]';
|
|
108
|
+
// The app shell, as the markup declares it. A <header>/<footer> inside a
|
|
109
|
+
// <section> is that section's own heading block, not the page banner (the
|
|
110
|
+
// HTML banner/contentinfo rule), and nothing inside the main content, an
|
|
111
|
+
// article or a dialog is shell however it is marked up.
|
|
112
|
+
const shellSel = 'nav, aside, header, footer, [role="navigation"], [role="banner"], [role="complementary"], [role="contentinfo"]';
|
|
113
|
+
const inShell = (el) => {
|
|
114
|
+
if (el.closest('main, [role="main"], article, [role="article"], dialog, [role="dialog"], [role="alertdialog"]')) return false;
|
|
115
|
+
for (let lm = el.closest(shellSel); lm; lm = lm.parentElement ? lm.parentElement.closest(shellSel) : null) {
|
|
116
|
+
const sectional = (lm.tagName === "HEADER" || lm.tagName === "FOOTER") && !lm.hasAttribute("role");
|
|
117
|
+
if (!sectional || !(lm.parentElement && lm.parentElement.closest("section"))) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
};
|
|
121
|
+
const ownFill = (s) => {
|
|
122
|
+
const c = parseColor(s.backgroundColor);
|
|
123
|
+
return (c !== null && c[3] > 0) || (!!s.backgroundImage && s.backgroundImage !== "none");
|
|
124
|
+
};
|
|
83
125
|
// Saturated (non-gray) color test on a parsed [r,g,b,a].
|
|
84
126
|
const isSaturated = (p) => p && (Math.max(p[0], p[1], p[2]) - Math.min(p[0], p[1], p[2])) > 40;
|
|
85
127
|
const hueDeg = (p) => {
|
|
@@ -91,6 +133,33 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
91
133
|
else h = (r - g) / (max - min) + 4;
|
|
92
134
|
return ((h * 60) + 360) % 360;
|
|
93
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
|
+
};
|
|
94
163
|
const vh = window.innerHeight;
|
|
95
164
|
let density = 0;
|
|
96
165
|
const all = document.querySelectorAll("body *");
|
|
@@ -138,6 +207,8 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
138
207
|
const slop = sideStripe || gradientText || glass || glow || aiGradient;
|
|
139
208
|
if (!interactive && !ownText && !slop) continue;
|
|
140
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;
|
|
141
212
|
out.push({
|
|
142
213
|
tag: el.tagName.toLowerCase(),
|
|
143
214
|
testid: el.getAttribute("data-testid"),
|
|
@@ -157,14 +228,25 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
157
228
|
padding: [px(s.paddingTop), px(s.paddingRight), px(s.paddingBottom), px(s.paddingLeft)],
|
|
158
229
|
marginV: [px(s.marginTop), px(s.marginBottom)],
|
|
159
230
|
radius: px(s.borderTopLeftRadius),
|
|
160
|
-
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) : "",
|
|
161
232
|
// srOnly text is DELIBERATELY a 1px box with overflow hidden — the exact
|
|
162
233
|
// signature of "text wider than its box". Flagging it reported the
|
|
163
234
|
// accessibility affordance itself as an accessibility defect.
|
|
164
235
|
clipped: !srOnly && el.scrollWidth > el.clientWidth + 2 && /hidden|clip/.test(s.overflowX) && s.textOverflow !== "ellipsis" && ownText,
|
|
236
|
+
...(target ? { target } : {}),
|
|
165
237
|
fixed: s.position === "fixed" || s.position === "sticky",
|
|
166
238
|
required: el.hasAttribute("required") || el.getAttribute("aria-required") === "true",
|
|
167
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 } : {}),
|
|
241
|
+
inputType: el.tagName === "INPUT" ? (el.getAttribute("type") || "text").toLowerCase() : "",
|
|
242
|
+
role: (el.getAttribute("role") || "").toLowerCase(),
|
|
243
|
+
filled: ownFill(s),
|
|
244
|
+
inForm: !!el.closest('form, [role="form"]'),
|
|
245
|
+
inRow: !!el.closest('tr, [role="row"]'),
|
|
246
|
+
inSearch: !!el.closest('search, [role="search"]'),
|
|
247
|
+
inBreadcrumb: !!el.closest('[aria-label*="breadcrumb" i], [class*="breadcrumb" i]'),
|
|
248
|
+
inFilter: interactive && inFilterOf(el),
|
|
249
|
+
shell: inShell(el),
|
|
168
250
|
sideStripe, gradientText, glass, glow, aiGradient,
|
|
169
251
|
});
|
|
170
252
|
}
|
|
@@ -231,10 +313,23 @@ function hueOf([r, g, b]) {
|
|
|
231
313
|
h = (r - g) / (max - min) + 4;
|
|
232
314
|
return Math.round((h * 60 + 360) % 360);
|
|
233
315
|
}
|
|
234
|
-
|
|
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
|
+
};
|
|
235
328
|
/** One wording for a small target, page or shell alike: the shell section's, so its prose is unchanged. */
|
|
236
|
-
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`;
|
|
237
330
|
const clippedTextDetail = (r) => `${label(r)} — text is clipped by its container`;
|
|
331
|
+
/** One wording for body-coloured links, page or shell alike. */
|
|
332
|
+
const indistinctDetail = (bodyColor) => `links with no underline in the body-text colour ${bodyColor}`;
|
|
238
333
|
/**
|
|
239
334
|
* Stable identity for one styled element, used to recognise the SAME component
|
|
240
335
|
* across routes.
|
|
@@ -282,9 +377,202 @@ function gridValues(top) {
|
|
|
282
377
|
.map((v) => `${v}px`)
|
|
283
378
|
.join(", ");
|
|
284
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;
|
|
386
|
+
/** Inputs that are buttons, not fields. */
|
|
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"]);
|
|
390
|
+
/** A button, or a link painted as one. Fields, selects and plain links never compete as actions. */
|
|
391
|
+
function buttonLike(r) {
|
|
392
|
+
if (r.tag === "button" || r.role === "button")
|
|
393
|
+
return true;
|
|
394
|
+
if (r.tag === "input")
|
|
395
|
+
return BUTTON_INPUT_TYPES.has(r.inputType);
|
|
396
|
+
return r.tag === "a" && r.filled;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The fields a user is asked to fill in and submit. Excluded: controls in a
|
|
400
|
+
* table row (a selection checkbox, or a select that edits the row in place),
|
|
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.
|
|
409
|
+
*/
|
|
410
|
+
function formFields(records) {
|
|
411
|
+
const candidates = records.filter((r) => r.interactive &&
|
|
412
|
+
(r.tag === "input" || r.tag === "select" || r.tag === "textarea") &&
|
|
413
|
+
!BUTTON_INPUT_TYPES.has(r.inputType) &&
|
|
414
|
+
r.inputType !== "search" &&
|
|
415
|
+
!r.inSearch &&
|
|
416
|
+
!r.inRow &&
|
|
417
|
+
!r.inFilter);
|
|
418
|
+
const inForm = candidates.filter((r) => r.inForm);
|
|
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
|
+
};
|
|
533
|
+
}
|
|
285
534
|
/** Below the WCAG 2.2 target-size minimum; inline links are exempt by that rule. */
|
|
286
|
-
function
|
|
287
|
-
|
|
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);
|
|
288
576
|
}
|
|
289
577
|
/**
|
|
290
578
|
* Reduce raw style records + page facts to a judgeable design digest + score.
|
|
@@ -307,11 +595,16 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
307
595
|
return { report: "DESIGN AUDIT: no visible styled elements found (page empty or not hydrated).", score: null, signatures: [], defects: [] };
|
|
308
596
|
}
|
|
309
597
|
const signatures = allRecords.map(styleSignature);
|
|
310
|
-
|
|
598
|
+
// Chrome is what the census has seen on most routes OR what the markup puts
|
|
599
|
+
// in a shell landmark. The census alone needs several audited routes before
|
|
600
|
+
// it knows anything, so the first pages of a run were scored with the whole
|
|
601
|
+
// shell in them and later ones without — a page's score depended on when it
|
|
602
|
+
// was audited. The landmark half is known from the first audit on.
|
|
603
|
+
const isChrome = (r) => r.shell || chromeKeys.has(styleSignature(r));
|
|
311
604
|
const chromeRecords = allRecords.filter(isChrome);
|
|
312
605
|
// Everything below scores THIS page. `records` deliberately shadows the full
|
|
313
606
|
// set so no rule can accidentally reach past the page's own content.
|
|
314
|
-
const records =
|
|
607
|
+
const records = allRecords.filter((r) => !isChrome(r));
|
|
315
608
|
if (records.length === 0) {
|
|
316
609
|
return { report: "DESIGN AUDIT: this page is entirely shared layout chrome — nothing page-specific to score.", score: null, signatures, defects: [] };
|
|
317
610
|
}
|
|
@@ -391,14 +684,16 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
391
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(", ")}`);
|
|
392
685
|
if (spacingLines.length > 0)
|
|
393
686
|
sections.push(`SPACING: ` + spacingLines.join("\n "));
|
|
394
|
-
// ---- 5. Touch/click targets below ~24px are hard to hit
|
|
395
|
-
//
|
|
396
|
-
|
|
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);
|
|
397
692
|
if (tiny.length > 0) {
|
|
398
693
|
sections.push(`TINY targets (${tiny.length}, smallest first):\n` +
|
|
399
694
|
tiny
|
|
400
695
|
.slice(0, 6)
|
|
401
|
-
.map((r) => ` ⚠ ${label(r)} — ${r.
|
|
696
|
+
.map((r) => ` ⚠ ${label(r)} — ${targetBox(r).w}×${targetBox(r).h}px`)
|
|
402
697
|
.join("\n"));
|
|
403
698
|
}
|
|
404
699
|
// ---- 6. Clipped text (overflow hidden without ellipsis) — content silently cut off.
|
|
@@ -443,15 +738,16 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
443
738
|
if (buttonHeights.length > 3) {
|
|
444
739
|
consistency.push(`→ buttons render at ${buttonHeights.length} different heights (${buttonHeights.slice(0, 8).join(", ")}px) — 1–2 control sizes read as a system`);
|
|
445
740
|
}
|
|
741
|
+
// Keyed on the layers that draw something (elevationKey); each example shows the layer that sets it apart.
|
|
446
742
|
const shadows = new Map();
|
|
447
|
-
for (const r of records)
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
}
|
|
450
748
|
if (shadows.size > 4) {
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
.slice(0, 3)
|
|
454
|
-
.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)}"`);
|
|
455
751
|
consistency.push(`→ ${shadows.size} distinct box-shadow styles (${top.join(", ")}…) — an elevation system needs 2–3 levels, not one per component`);
|
|
456
752
|
}
|
|
457
753
|
if (consistency.length > 0)
|
|
@@ -462,20 +758,15 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
462
758
|
if (pureBlack.length > 0) {
|
|
463
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`);
|
|
464
760
|
}
|
|
465
|
-
const grayFreq = new Map();
|
|
466
761
|
const hueFamilies = new Map();
|
|
762
|
+
const colours = [];
|
|
467
763
|
for (const r of records) {
|
|
468
764
|
for (const c of [r.color, r.bg]) {
|
|
469
765
|
const p = parseRgb(c);
|
|
470
766
|
if (!p)
|
|
471
767
|
continue;
|
|
472
768
|
const rgb = [p[0], p[1], p[2]];
|
|
473
|
-
|
|
474
|
-
const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
475
|
-
if (spread <= 10 && avg > 20 && avg < 245) {
|
|
476
|
-
const key = rgb.join(",");
|
|
477
|
-
grayFreq.set(key, (grayFreq.get(key) ?? 0) + 1);
|
|
478
|
-
}
|
|
769
|
+
colours.push(rgb);
|
|
479
770
|
const hue = hueOf(rgb);
|
|
480
771
|
if (hue !== null) {
|
|
481
772
|
const bucket = (Math.round(hue / 30) * 30) % 360;
|
|
@@ -483,12 +774,10 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
483
774
|
}
|
|
484
775
|
}
|
|
485
776
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
.map(([k]) => `rgb(${k})`);
|
|
491
|
-
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`);
|
|
492
781
|
}
|
|
493
782
|
if (hueFamilies.size > 5) {
|
|
494
783
|
const hues = [...hueFamilies.keys()].sort((a, b) => a - b);
|
|
@@ -553,6 +842,20 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
553
842
|
}
|
|
554
843
|
if (structure.length > 0)
|
|
555
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
|
+
}
|
|
556
859
|
// ---- 11. AFFORDANCES: keyboard focus visibility (trusted-Tab sampled engine-side) + indistinguishable links.
|
|
557
860
|
const affordances = [];
|
|
558
861
|
const focusless = page.focusSamples.filter((f) => !f.indicator);
|
|
@@ -567,7 +870,12 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
567
870
|
if (r.textLen > 40 && r.tag !== "a" && r.color !== "unknown")
|
|
568
871
|
bodyColorFreq.set(r.color, (bodyColorFreq.get(r.color) ?? 0) + 1);
|
|
569
872
|
const dominantBody = [...bodyColorFreq.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
570
|
-
const
|
|
873
|
+
const looksLikeBody = (r) => r.tag === "a" && r.textLen > 0 && !r.underline && r.color === dominantBody;
|
|
874
|
+
const indistinct = dominantBody ? records.filter(looksLikeBody) : [];
|
|
875
|
+
// Navigation links are this rule's commonest subject and usually sit in the
|
|
876
|
+
// shell, so the shell's are measured too, against the page's body colour, and
|
|
877
|
+
// reported as the shell's.
|
|
878
|
+
const chromeIndistinct = dominantBody ? chromeRecords.filter(looksLikeBody) : [];
|
|
571
879
|
if (dominantBody) {
|
|
572
880
|
if (indistinct.length > 0) {
|
|
573
881
|
affordances.push(`→ ${indistinct.length} link(s) with no underline AND the same color as body text (e.g. ${label(indistinct[0])}) — invisible as links`);
|
|
@@ -619,11 +927,14 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
619
927
|
// suite never answers.
|
|
620
928
|
const effort = [];
|
|
621
929
|
const pageBg = parseRgb(records.find((r) => r.bg && r.bg.startsWith("rgb"))?.bg ?? "") ?? [255, 255, 255, 1];
|
|
622
|
-
// A "prominent" action =
|
|
623
|
-
// from the page background, at a clickable
|
|
624
|
-
// on, so it is the page's implied primary
|
|
930
|
+
// A "prominent" action = a button (or a link painted as one) that paints its
|
|
931
|
+
// own background, clearly departing from the page background, at a clickable
|
|
932
|
+
// size. That is what the eye lands on, so it is the page's implied primary
|
|
933
|
+
// action. Fields never qualify — a white text field on a grey page departs
|
|
934
|
+
// from the page background too — nor does a ghost button showing a card's
|
|
935
|
+
// background, nor a breadcrumb, which is a way back rather than an action.
|
|
625
936
|
const prominent = records.filter((r) => {
|
|
626
|
-
if (!r.interactive || r.rect.w < 60 || r.rect.h < 24)
|
|
937
|
+
if (!r.interactive || !buttonLike(r) || !r.filled || r.inBreadcrumb || r.rect.w < 60 || r.rect.h < 24)
|
|
627
938
|
return false;
|
|
628
939
|
const bg = parseRgb(r.bg);
|
|
629
940
|
if (!bg)
|
|
@@ -647,7 +958,7 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
647
958
|
effort.push(`→ the primary action (${label(nearest)}) sits ${Math.round(nearest.rect.y - foldH)}px below the fold — the user must scroll before seeing what this page is for`);
|
|
648
959
|
}
|
|
649
960
|
// Form burden: how much is being asked, and how much of it is actually needed.
|
|
650
|
-
const fields = records
|
|
961
|
+
const fields = formFields(records);
|
|
651
962
|
if (fields.length >= 5) {
|
|
652
963
|
const req = fields.filter((r) => r.required).length;
|
|
653
964
|
if (req === 0) {
|
|
@@ -672,13 +983,13 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
672
983
|
const gridPct = pad.n > 0 ? 100 - pad.pct : 100;
|
|
673
984
|
const summary = `SYSTEM SUMMARY: ${sizes.length} font sizes (≤7 healthy) · ${families.length} families (≤2) · ` +
|
|
674
985
|
`${buttonRadii.length} button radii (1–2) · ${buttonHeights.length} button heights (1–3) · ${shadows.size} shadow styles (≤3) · ` +
|
|
675
|
-
`${
|
|
986
|
+
`${grays.length} grays (4–6) · ${hueFamilies.size} accent hue families (1–3 + status) · ${gridPct}% spacing on 4px grid · ` +
|
|
676
987
|
`${page.density} interactive elements in first viewport${page.density > 40 ? " (dense — consider progressive disclosure)" : ""}`;
|
|
677
988
|
// ---- Multi-indicator PAGE SCORE. Deductive: start at 100 per dimension,
|
|
678
989
|
// subtract per measured issue, floor at 0. Weights favour a11y and
|
|
679
990
|
// task clarity — a page can be pretty and still hard to use.
|
|
680
991
|
const floor0 = (n) => Math.max(0, Math.round(n));
|
|
681
|
-
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);
|
|
682
993
|
const craftScore = floor0(100 - readability.length * 4 - palette.length * 4 - slopTells.length * 5 - clipped.length * 3 - distorted.length * 4);
|
|
683
994
|
const consistencyScore = floor0(100 -
|
|
684
995
|
consistency.length * 8 -
|
|
@@ -703,13 +1014,14 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
703
1014
|
const chromeSection = [];
|
|
704
1015
|
const chromeDefects = [];
|
|
705
1016
|
if (chromeRecords.length > 0) {
|
|
706
|
-
chromeDefects.push(...contrastFailures(chromeRecords).map((detail) => ({ rule: "contrast", detail, chrome: true })), ...chromeRecords.filter((r) =>
|
|
707
|
-
|
|
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 }] : []));
|
|
1018
|
+
// A convention (indistinct-link) is a → line, as it is on the page; the rest are measurable defects.
|
|
1019
|
+
const chromeIssues = chromeDefects.map((d) => `${d.rule === "indistinct-link" ? "→" : "⚠"} ${d.detail}`);
|
|
708
1020
|
if (chromeIssues.length > 0) {
|
|
709
1021
|
chromeSection.push(`SHARED CHROME (${chromeRecords.length} shell elements, excluded from this page's score and reported here instead):\n` +
|
|
710
1022
|
[...new Set(chromeIssues)]
|
|
711
1023
|
.slice(0, CHROME_ISSUE_CAP)
|
|
712
|
-
.map((s) => `
|
|
1024
|
+
.map((s) => ` ${s}`)
|
|
713
1025
|
.join("\n") +
|
|
714
1026
|
`\n → these belong to the app shell and recur on every page that renders it. File ONE finding for the shell, not one per page.`);
|
|
715
1027
|
}
|
|
@@ -739,7 +1051,7 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
739
1051
|
// values on ten pages are one entry on ten routes, and the entry's fingerprint does not move when content does.
|
|
740
1052
|
...(pad.n > 10 && pad.pct > 20 ? [{ rule: "off-grid-spacing", detail: `paddings off a 4px grid: ${gridValues(pad.top)}` }] : []),
|
|
741
1053
|
...(mar.n > 10 && mar.pct > 20 ? [{ rule: "off-grid-spacing", detail: `vertical margins off a 4px grid: ${gridValues(mar.top)}` }] : []),
|
|
742
|
-
...(indistinct.length > 0 ? [{ rule: "indistinct-link", detail:
|
|
1054
|
+
...(indistinct.length > 0 ? [{ rule: "indistinct-link", detail: indistinctDetail(dominantBody ?? "") }] : []),
|
|
743
1055
|
...chromeDefects,
|
|
744
1056
|
];
|
|
745
1057
|
return { report, score, signatures, defects };
|