scenescout 3.15.0 → 3.17.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 +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. 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
+ }
@@ -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, 80) : "",
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
- 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
+ };
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.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`;
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, and inputs that are buttons. When some fields sit in a <form>,
326
- * only those count: the rest of the page (filters, toolbars) is not part of
327
- * what gets submitted. A page with no <form> at all is judged on every field,
328
- * because many apps build their forms without the element.
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 tooSmall(r) {
342
- 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);
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. Inline links are
455
- // exempt (mirrors the WCAG 2.2 target-size exception); worst offenders first.
456
- 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);
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.rect.w}×${r.rect.h}px`)
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
- if (r.shadow)
509
- 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
+ }
510
748
  if (shadows.size > 4) {
511
- const top = [...shadows.entries()]
512
- .sort((a, b) => b[1] - a[1])
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
- const spread = Math.max(...rgb) - Math.min(...rgb);
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
- if (grayFreq.size > 6) {
547
- const top = [...grayFreq.entries()]
548
- .sort((a, b) => b[1] - a[1])
549
- .slice(0, 5)
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
- `${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 · ` +
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) => 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 })), ...(chromeIndistinct.length > 0 ? [{ rule: "indistinct-link", detail: indistinctDetail(dominantBody ?? ""), chrome: true }] : []));
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) {