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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -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, 80) : "",
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
- const label = (r) => (r.testid ? `[${r.testid}]` : `<${r.tag}> "${r.text.slice(0, 30) || "(no text)"}"`);
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.rect.w)}×${Math.round(r.rect.h)}px tap target`;
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 tooSmall(r) {
287
- return r.tag !== "a" && (r.rect.h < 24 || r.rect.w < 24) && r.rect.h > 0;
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
- const isChrome = (r) => chromeKeys.has(styleSignature(r));
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 = chromeKeys.size > 0 ? allRecords.filter((r) => !isChrome(r)) : allRecords;
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. Inline links are
395
- // exempt (mirrors the WCAG 2.2 target-size exception); worst offenders first.
396
- const tiny = records.filter((r) => r.interactive && tooSmall(r)).sort((a, b) => a.rect.w * a.rect.h - b.rect.w * b.rect.h);
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.rect.w}×${r.rect.h}px`)
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
- if (r.shadow)
449
- shadows.set(r.shadow, (shadows.get(r.shadow) ?? 0) + 1);
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 top = [...shadows.entries()]
452
- .sort((a, b) => b[1] - a[1])
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
- const spread = Math.max(...rgb) - Math.min(...rgb);
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
- if (grayFreq.size > 6) {
487
- const top = [...grayFreq.entries()]
488
- .sort((a, b) => b[1] - a[1])
489
- .slice(0, 5)
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 indistinct = dominantBody ? records.filter((r) => r.tag === "a" && r.textLen > 0 && !r.underline && r.color === dominantBody) : [];
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 = filled control whose background clearly departs
623
- // from the page background, at a clickable size. That is what the eye lands
624
- // on, so it is the page's implied primary action.
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.filter((r) => r.interactive && /input|select|textarea/.test(r.tag));
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
- `${grayFreq.size} grays (4–6) · ${hueFamilies.size} accent hue families (1–3 + status) · ${gridPct}% spacing on 4px grid · ` +
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) => r.interactive && tooSmall(r)).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 })));
707
- const chromeIssues = chromeDefects.map((d) => d.detail);
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) => ` ⚠ ${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: `links with no underline in the body-text colour ${dominantBody}` }] : []),
1054
+ ...(indistinct.length > 0 ? [{ rule: "indistinct-link", detail: indistinctDetail(dominantBody ?? "") }] : []),
743
1055
  ...chromeDefects,
744
1056
  ];
745
1057
  return { report, score, signatures, defects };