scenescout 1.0.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computed-style design audit — visual/UX understanding WITHOUT pixels.
|
|
3
|
+
*
|
|
4
|
+
* The renderer already knows every element's typography, colors, spacing, and
|
|
5
|
+
* geometry. We extract that (one in-page pass) and reduce it to a measurable
|
|
6
|
+
* design digest. Two kinds of output, deliberately distinct:
|
|
7
|
+
*
|
|
8
|
+
* ⚠ measurable defects — contrast failures, clipped text, tiny targets,
|
|
9
|
+
* aspect-distorted images, horizontal overflow, missing focus states
|
|
10
|
+
* → craft suggestions — line measure, line-height rhythm, palette
|
|
11
|
+
* discipline (gray census, accent hue count), elevation consistency,
|
|
12
|
+
* control sizing, heading structure, pure-black body text
|
|
13
|
+
*
|
|
14
|
+
* The suggestion tier is the point of the tool: e2e suites answer "does it
|
|
15
|
+
* work?" as a binary; this answers "how could it be better?" with concrete
|
|
16
|
+
* numbers ("~142ch per line", "9 distinct grays") an LLM can judge with
|
|
17
|
+
* product context and turn into actionable feedback. The heuristics encode
|
|
18
|
+
* the computable subset of studio-craft checklists (impeccable.style et al.):
|
|
19
|
+
* readable measure, breathing line-height, a deliberate spacing scale,
|
|
20
|
+
* limited type sizes, near-black over #000, a gray scale instead of ad-hoc
|
|
21
|
+
* grays, a focused accent palette, one elevation system, consistent
|
|
22
|
+
* controls, visible keyboard focus.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* In-page collector, shipped as a string expression (immune to loader
|
|
26
|
+
* transforms — same rationale as the interactables collector).
|
|
27
|
+
*/
|
|
28
|
+
export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
29
|
+
const out = [];
|
|
30
|
+
const px = (v) => { const n = parseFloat(v); return isNaN(n) ? 0 : Math.round(n * 10) / 10; };
|
|
31
|
+
// Canvas fillStyle normalizes ANY CSS color (oklch, lab, color(), named) to
|
|
32
|
+
// #rrggbb or rgba() — Tailwind v4 palettes compute to oklch and would
|
|
33
|
+
// otherwise read as unparseable (and wrongly composite to white).
|
|
34
|
+
const cctx = document.createElement("canvas").getContext("2d");
|
|
35
|
+
const parseColor = (c) => {
|
|
36
|
+
if (!c || c === "transparent") return null;
|
|
37
|
+
let m = c.match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
|
|
38
|
+
if (!m && cctx) {
|
|
39
|
+
try {
|
|
40
|
+
cctx.fillStyle = "#010203";
|
|
41
|
+
cctx.fillStyle = c;
|
|
42
|
+
const norm = cctx.fillStyle;
|
|
43
|
+
const hex = norm.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
44
|
+
if (hex) return [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16), 1];
|
|
45
|
+
m = norm.match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
|
|
46
|
+
} catch (e) { /* unparseable — treated as absent */ }
|
|
47
|
+
}
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
|
|
50
|
+
};
|
|
51
|
+
const normColor = (c) => {
|
|
52
|
+
const p = parseColor(c);
|
|
53
|
+
return p ? "rgba(" + p[0] + ", " + p[1] + ", " + p[2] + ", " + p[3] + ")" : "unknown";
|
|
54
|
+
};
|
|
55
|
+
// Effective background: COMPOSITE translucent ancestor layers (a chip with
|
|
56
|
+
// rgba(...,0.12) over white is nearly white — treating it as opaque produces
|
|
57
|
+
// false WCAG failures). Stops at the first opaque layer; white fallback.
|
|
58
|
+
const effBg = (el) => {
|
|
59
|
+
const layers = [];
|
|
60
|
+
let node = el;
|
|
61
|
+
for (let i = 0; node && i < 20; i++, node = node.parentElement) {
|
|
62
|
+
const s = window.getComputedStyle(node);
|
|
63
|
+
if (s.backgroundImage && s.backgroundImage !== "none") return "image";
|
|
64
|
+
const c = parseColor(s.backgroundColor);
|
|
65
|
+
if (!c || c[3] === 0) continue;
|
|
66
|
+
layers.push(c);
|
|
67
|
+
if (c[3] >= 1) break;
|
|
68
|
+
}
|
|
69
|
+
let base = [255, 255, 255];
|
|
70
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
71
|
+
const [r, g, b, a] = layers[i];
|
|
72
|
+
base = [r * a + base[0] * (1 - a), g * a + base[1] * (1 - a), b * a + base[2] * (1 - a)];
|
|
73
|
+
}
|
|
74
|
+
return "rgb(" + Math.round(base[0]) + ", " + Math.round(base[1]) + ", " + Math.round(base[2]) + ")";
|
|
75
|
+
};
|
|
76
|
+
const hasOwnText = (el) => {
|
|
77
|
+
for (const n of el.childNodes) {
|
|
78
|
+
if (n.nodeType === 3 && n.textContent.trim().length > 0) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
const interactiveSel = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [onclick]';
|
|
83
|
+
// Saturated (non-gray) color test on a parsed [r,g,b,a].
|
|
84
|
+
const isSaturated = (p) => p && (Math.max(p[0], p[1], p[2]) - Math.min(p[0], p[1], p[2])) > 40;
|
|
85
|
+
const hueDeg = (p) => {
|
|
86
|
+
const [r, g, b] = p; const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
87
|
+
if (max === min) return 0;
|
|
88
|
+
let h;
|
|
89
|
+
if (max === r) h = ((g - b) / (max - min)) % 6;
|
|
90
|
+
else if (max === g) h = (b - r) / (max - min) + 2;
|
|
91
|
+
else h = (r - g) / (max - min) + 4;
|
|
92
|
+
return ((h * 60) + 360) % 360;
|
|
93
|
+
};
|
|
94
|
+
const vh = window.innerHeight;
|
|
95
|
+
let density = 0;
|
|
96
|
+
const all = document.querySelectorAll("body *");
|
|
97
|
+
for (const el of all) {
|
|
98
|
+
if (out.length >= 500) break;
|
|
99
|
+
const rect = el.getBoundingClientRect();
|
|
100
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
101
|
+
const s = window.getComputedStyle(el);
|
|
102
|
+
if (s.visibility === "hidden" || s.display === "none") continue;
|
|
103
|
+
const interactive = el.matches(interactiveSel);
|
|
104
|
+
if (interactive && rect.top < vh && rect.bottom > 0) density += 1;
|
|
105
|
+
const ownText = hasOwnText(el);
|
|
106
|
+
// The visually-hidden ("sr-only") idiom: a ~1px clipped box that exists to
|
|
107
|
+
// carry text for screen readers. Every published variant of it trips the
|
|
108
|
+
// clipped-text heuristic by construction, so identify it once here.
|
|
109
|
+
const srOnly =
|
|
110
|
+
(rect.width <= 1 && rect.height <= 1) ||
|
|
111
|
+
/inset\\(\\s*50%|rect\\(\\s*0(px)?[\\s,]+0(px)?[\\s,]+0(px)?[\\s,]+0(px)?\\s*\\)/.test(
|
|
112
|
+
(s.clipPath || "") + " " + (s.clip || ""),
|
|
113
|
+
);
|
|
114
|
+
// AI-slop tells are computed for containers too (cards/heroes rarely have
|
|
115
|
+
// own text), so evaluate them BEFORE the text/interactive filter.
|
|
116
|
+
const leftW = px(s.borderLeftWidth), rightW = px(s.borderRightWidth), topW = px(s.borderTopWidth);
|
|
117
|
+
const sideStripe =
|
|
118
|
+
rect.width > 80 &&
|
|
119
|
+
((leftW > 1 && leftW > topW && isSaturated(parseColor(s.borderLeftColor))) ||
|
|
120
|
+
(rightW > 1 && rightW > topW && isSaturated(parseColor(s.borderRightColor))));
|
|
121
|
+
const bgImg = s.backgroundImage && s.backgroundImage !== "none" ? s.backgroundImage : "";
|
|
122
|
+
const gradientText = /gradient/.test(bgImg) && ((s.webkitBackgroundClip || s.backgroundClip || "") + "").includes("text");
|
|
123
|
+
const rawBgA = parseColor(s.backgroundColor);
|
|
124
|
+
const glass = ((s.backdropFilter || s.webkitBackdropFilter || "") + "").includes("blur") && (!rawBgA || rawBgA[3] < 0.9);
|
|
125
|
+
let glow = false;
|
|
126
|
+
if (s.boxShadow && s.boxShadow !== "none") {
|
|
127
|
+
const shadowColor = parseColor((s.boxShadow.match(/rgba?\\([^)]+\\)/) || [""])[0]);
|
|
128
|
+
const lengths = (s.boxShadow.match(/-?[0-9.]+px/g) || []).map(parseFloat);
|
|
129
|
+
glow = isSaturated(shadowColor) && lengths.length >= 3 && Math.abs(lengths[2]) > 16;
|
|
130
|
+
}
|
|
131
|
+
let aiGradient = false;
|
|
132
|
+
if (/gradient/.test(bgImg) && !gradientText && rect.width > 100) {
|
|
133
|
+
for (const cm of bgImg.match(/rgba?\\([^)]+\\)/g) || []) {
|
|
134
|
+
const p = parseColor(cm);
|
|
135
|
+
if (isSaturated(p) && hueDeg(p) >= 248 && hueDeg(p) <= 295) { aiGradient = true; break; }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const slop = sideStripe || gradientText || glass || glow || aiGradient;
|
|
139
|
+
if (!interactive && !ownText && !slop) continue;
|
|
140
|
+
const fullText = ownText ? (el.textContent || "").trim().replace(/\\s+/g, " ") : "";
|
|
141
|
+
out.push({
|
|
142
|
+
tag: el.tagName.toLowerCase(),
|
|
143
|
+
testid: el.getAttribute("data-testid"),
|
|
144
|
+
text: fullText.slice(0, 50),
|
|
145
|
+
textLen: fullText.length,
|
|
146
|
+
interactive,
|
|
147
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
148
|
+
fontSize: px(s.fontSize),
|
|
149
|
+
fontWeight: parseInt(s.fontWeight, 10) || 400,
|
|
150
|
+
fontFamily: (s.fontFamily || "").split(",")[0].replace(/["']/g, "").trim(),
|
|
151
|
+
lineHeight: s.lineHeight === "normal" ? 0 : px(s.lineHeight),
|
|
152
|
+
textTransform: s.textTransform || "",
|
|
153
|
+
textAlign: s.textAlign || "",
|
|
154
|
+
underline: ((s.textDecorationLine || s.textDecoration || "") + "").includes("underline"),
|
|
155
|
+
color: normColor(s.color),
|
|
156
|
+
bg: effBg(el),
|
|
157
|
+
padding: [px(s.paddingTop), px(s.paddingRight), px(s.paddingBottom), px(s.paddingLeft)],
|
|
158
|
+
marginV: [px(s.marginTop), px(s.marginBottom)],
|
|
159
|
+
radius: px(s.borderTopLeftRadius),
|
|
160
|
+
shadow: s.boxShadow && s.boxShadow !== "none" ? s.boxShadow.replace(/\\s+/g, " ").slice(0, 80) : "",
|
|
161
|
+
// srOnly text is DELIBERATELY a 1px box with overflow hidden — the exact
|
|
162
|
+
// signature of "text wider than its box". Flagging it reported the
|
|
163
|
+
// accessibility affordance itself as an accessibility defect.
|
|
164
|
+
clipped: !srOnly && el.scrollWidth > el.clientWidth + 2 && /hidden|clip/.test(s.overflowX) && s.textOverflow !== "ellipsis" && ownText,
|
|
165
|
+
fixed: s.position === "fixed" || s.position === "sticky",
|
|
166
|
+
required: el.hasAttribute("required") || el.getAttribute("aria-required") === "true",
|
|
167
|
+
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),
|
|
168
|
+
sideStripe, gradientText, glass, glow, aiGradient,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const headings = [];
|
|
172
|
+
for (const h of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) {
|
|
173
|
+
if (headings.length >= 30) break;
|
|
174
|
+
const r = h.getBoundingClientRect();
|
|
175
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
176
|
+
headings.push({ level: Number(h.tagName[1]), size: px(window.getComputedStyle(h).fontSize), text: (h.textContent || "").trim().slice(0, 30) });
|
|
177
|
+
}
|
|
178
|
+
const images = [];
|
|
179
|
+
for (const img of document.querySelectorAll("img")) {
|
|
180
|
+
if (images.length >= 40) break;
|
|
181
|
+
const r = img.getBoundingClientRect();
|
|
182
|
+
if (img.naturalWidth <= 1 || img.naturalHeight <= 1 || r.width < 24 || r.height < 24) continue;
|
|
183
|
+
const tid = img.getAttribute("data-testid");
|
|
184
|
+
const label = tid ? "[" + tid + "]" : (img.getAttribute("alt") || (img.getAttribute("src") || "").split("/").pop() || "img").slice(0, 40);
|
|
185
|
+
images.push({ label, nw: img.naturalWidth, nh: img.naturalHeight, rw: Math.round(r.width), rh: Math.round(r.height) });
|
|
186
|
+
}
|
|
187
|
+
const page = {
|
|
188
|
+
scrollW: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),
|
|
189
|
+
clientW: window.innerWidth,
|
|
190
|
+
headings,
|
|
191
|
+
images,
|
|
192
|
+
density,
|
|
193
|
+
focusSamples: [],
|
|
194
|
+
};
|
|
195
|
+
return { records: out, page };
|
|
196
|
+
})()`;
|
|
197
|
+
function parseRgb(color) {
|
|
198
|
+
const m = color.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?/);
|
|
199
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])] : null;
|
|
200
|
+
}
|
|
201
|
+
function luminance([r, g, b]) {
|
|
202
|
+
const chan = (c) => {
|
|
203
|
+
const s = c / 255;
|
|
204
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
205
|
+
};
|
|
206
|
+
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b);
|
|
207
|
+
}
|
|
208
|
+
export function contrastRatio(fg, bg) {
|
|
209
|
+
const f = parseRgb(fg);
|
|
210
|
+
const b = parseRgb(bg);
|
|
211
|
+
if (!f || !b)
|
|
212
|
+
return null;
|
|
213
|
+
// Composite a translucent text color over the (already-opaque) background.
|
|
214
|
+
const [fr, fg_, fb, fa] = f;
|
|
215
|
+
const eff = [fr * fa + b[0] * (1 - fa), fg_ * fa + b[1] * (1 - fa), fb * fa + b[2] * (1 - fa)];
|
|
216
|
+
const [l1, l2] = [luminance(eff), luminance([b[0], b[1], b[2]])].sort((a, z) => z - a);
|
|
217
|
+
return (l1 + 0.05) / (l2 + 0.05);
|
|
218
|
+
}
|
|
219
|
+
/** Hue in degrees (0–360) for a saturated color; null for grays. */
|
|
220
|
+
function hueOf([r, g, b]) {
|
|
221
|
+
const max = Math.max(r, g, b);
|
|
222
|
+
const min = Math.min(r, g, b);
|
|
223
|
+
if (max - min <= 40)
|
|
224
|
+
return null; // low chroma — not an accent
|
|
225
|
+
let h;
|
|
226
|
+
if (max === r)
|
|
227
|
+
h = ((g - b) / (max - min)) % 6;
|
|
228
|
+
else if (max === g)
|
|
229
|
+
h = (b - r) / (max - min) + 2;
|
|
230
|
+
else
|
|
231
|
+
h = (r - g) / (max - min) + 4;
|
|
232
|
+
return Math.round((h * 60 + 360) % 360);
|
|
233
|
+
}
|
|
234
|
+
const label = (r) => (r.testid ? `[${r.testid}]` : `<${r.tag}> "${r.text.slice(0, 30) || "(no text)"}"`);
|
|
235
|
+
/**
|
|
236
|
+
* Stable identity for one styled element, used to recognise the SAME component
|
|
237
|
+
* across routes.
|
|
238
|
+
*
|
|
239
|
+
* Deliberately not the coverage element key: that one is built from the ARIA
|
|
240
|
+
* role and accessible name, and a style record carries neither — it has a tag
|
|
241
|
+
* and its own text. A separate census keyed the way the audit actually sees
|
|
242
|
+
* things is what lets shared shell elements without a testid (a notification
|
|
243
|
+
* badge rendering "18") be recognised as one component rather than one per page.
|
|
244
|
+
*/
|
|
245
|
+
export function styleSignature(r) {
|
|
246
|
+
if (r.testid)
|
|
247
|
+
return `tid:${r.testid}`;
|
|
248
|
+
return `${r.tag}:${r.text.slice(0, SIGNATURE_TEXT_LEN).toLowerCase().replace(/\s+/g, " ").trim()}`;
|
|
249
|
+
}
|
|
250
|
+
/** How much own-text identifies a component across pages without over-fragmenting it. */
|
|
251
|
+
const SIGNATURE_TEXT_LEN = 40;
|
|
252
|
+
/** Shell issues listed individually before the reader stops reading. */
|
|
253
|
+
const CHROME_ISSUE_CAP = 6;
|
|
254
|
+
/**
|
|
255
|
+
* WCAG contrast check over a record set. Shared by the page pass and the
|
|
256
|
+
* shared-chrome pass so the thresholds (and the message shape a reader learns
|
|
257
|
+
* to scan) are stated once rather than copied.
|
|
258
|
+
*/
|
|
259
|
+
function contrastFailures(records) {
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const r of records) {
|
|
262
|
+
if (!r.text || r.bg === "image" || r.bg === "unknown" || r.color === "unknown")
|
|
263
|
+
continue;
|
|
264
|
+
const ratio = contrastRatio(r.color, r.bg);
|
|
265
|
+
if (ratio === null)
|
|
266
|
+
continue;
|
|
267
|
+
const isLarge = r.fontSize >= 24 || (r.fontSize >= 18.7 && r.fontWeight >= 700);
|
|
268
|
+
const threshold = isLarge ? 3 : 4.5;
|
|
269
|
+
if (ratio < threshold)
|
|
270
|
+
out.push(`${label(r)} — ${ratio.toFixed(2)}:1 (needs ${threshold}:1) ${r.color} on ${r.bg}`);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/** Below the WCAG 2.2 target-size minimum; inline links are exempt by that rule. */
|
|
275
|
+
function tooSmall(r) {
|
|
276
|
+
return r.tag !== "a" && (r.rect.h < 24 || r.rect.w < 24) && r.rect.h > 0;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Reduce raw style records + page facts to a judgeable design digest + score.
|
|
280
|
+
*
|
|
281
|
+
* `chromeKeys` holds the signatures of elements that appear on most routes —
|
|
282
|
+
* the sidebar, header, breadcrumb bar. They are scored ONCE, globally, not once
|
|
283
|
+
* per page, for exactly the reason coverage already folds them: a contrast
|
|
284
|
+
* failure in the shared shell is one defect. Counting it per page did three
|
|
285
|
+
* kinds of damage — it filed the same finding once per route (five findings for
|
|
286
|
+
* two CSS declarations in one observed run), it drowned every page's TASK
|
|
287
|
+
* EFFICIENCY line in the same two dozen nav links, and because chrome is spread
|
|
288
|
+
* unevenly (breadcrumbs only on nested routes) it silently reordered the
|
|
289
|
+
* worst-pages ranking the report leads with.
|
|
290
|
+
*
|
|
291
|
+
* Returns the signatures it saw so the caller can keep the census current.
|
|
292
|
+
*/
|
|
293
|
+
export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
294
|
+
const { records: allRecords, page } = payload;
|
|
295
|
+
if (allRecords.length === 0) {
|
|
296
|
+
return { report: "DESIGN AUDIT: no visible styled elements found (page empty or not hydrated).", score: null, signatures: [] };
|
|
297
|
+
}
|
|
298
|
+
const signatures = allRecords.map(styleSignature);
|
|
299
|
+
const isChrome = (r) => chromeKeys.has(styleSignature(r));
|
|
300
|
+
const chromeRecords = allRecords.filter(isChrome);
|
|
301
|
+
// Everything below scores THIS page. `records` deliberately shadows the full
|
|
302
|
+
// set so no rule can accidentally reach past the page's own content.
|
|
303
|
+
const records = chromeKeys.size > 0 ? allRecords.filter((r) => !isChrome(r)) : allRecords;
|
|
304
|
+
if (records.length === 0) {
|
|
305
|
+
return { report: "DESIGN AUDIT: this page is entirely shared layout chrome — nothing page-specific to score.", score: null, signatures };
|
|
306
|
+
}
|
|
307
|
+
const sections = [];
|
|
308
|
+
// ---- 1. CONTRAST (WCAG): normal text needs 4.5:1, large text (≥24px, or ≥18.7px bold) needs 3:1.
|
|
309
|
+
const contrastFails = contrastFailures(records);
|
|
310
|
+
if (contrastFails.length > 0) {
|
|
311
|
+
sections.push(`CONTRAST failures (${contrastFails.length}):\n` +
|
|
312
|
+
contrastFails
|
|
313
|
+
.slice(0, 8)
|
|
314
|
+
.map((s) => ` ⚠ ${s}`)
|
|
315
|
+
.join("\n"));
|
|
316
|
+
}
|
|
317
|
+
// ---- 2. TYPOGRAPHY entropy: a page using >7 font sizes or >2 families usually
|
|
318
|
+
// lacks a scale, and sizes <1px apart are drift, not deliberate steps.
|
|
319
|
+
const sizes = [...new Set(records.filter((r) => r.text).map((r) => r.fontSize))].sort((a, b) => a - b);
|
|
320
|
+
const families = [...new Set(records.filter((r) => r.text && r.fontFamily).map((r) => r.fontFamily))];
|
|
321
|
+
const nearDupSizes = sizes.filter((s, i) => i > 0 && s - sizes[i - 1] < 1 && s - sizes[i - 1] > 0);
|
|
322
|
+
if (sizes.length > 7 || families.length > 2 || nearDupSizes.length > 0) {
|
|
323
|
+
sections.push(`TYPOGRAPHY: ${sizes.length} distinct font sizes (${sizes.slice(0, 12).join(", ")}${sizes.length > 12 ? "…" : ""})` +
|
|
324
|
+
(families.length > 2 ? ` · ${families.length} font families (${families.slice(0, 4).join(", ")})` : "") +
|
|
325
|
+
(nearDupSizes.length > 0
|
|
326
|
+
? `\n → sizes <1px apart (${nearDupSizes.join(", ")}) are drift, not scale steps — a type scale wants ≥1.15× between steps`
|
|
327
|
+
: ""));
|
|
328
|
+
}
|
|
329
|
+
// ---- 3. READABILITY: measure, line-height rhythm, body size, justified text, long ALL-CAPS.
|
|
330
|
+
// Per-check caps keep any single symptom from drowning the section; one
|
|
331
|
+
// overall truncation caps the section itself.
|
|
332
|
+
const readability = [];
|
|
333
|
+
const capped = (cap, hits) => void readability.push(...hits.slice(0, cap));
|
|
334
|
+
const prose = records.filter((r) => r.textLen > 80 && !r.interactive);
|
|
335
|
+
capped(6, prose
|
|
336
|
+
.filter((p) => p.textLen > 150)
|
|
337
|
+
.map((r) => ({ r, chars: Math.round(r.rect.w / (r.fontSize * 0.5)) }))
|
|
338
|
+
.filter(({ chars }) => chars > 95)
|
|
339
|
+
.map(({ r, chars }) => `→ ${label(r)} — ~${chars} characters per line (65–75ch ideal, 90 max); cap the text column's width`));
|
|
340
|
+
capped(3, prose
|
|
341
|
+
.filter((r) => r.lineHeight > 0 && r.fontSize <= 20)
|
|
342
|
+
.map((r) => ({ r, ratio: Math.round((r.lineHeight / r.fontSize) * 100) / 100 }))
|
|
343
|
+
.filter(({ ratio }) => ratio < 1.25 || ratio > 2.0)
|
|
344
|
+
.map(({ r, ratio }) => ratio < 1.25
|
|
345
|
+
? `→ ${label(r)} — line-height ${ratio} is cramped for body prose (1.4–1.6 breathes)`
|
|
346
|
+
: `→ ${label(r)} — line-height ${ratio} is loose; lines drift apart`));
|
|
347
|
+
capped(2, prose.filter((p) => p.textLen > 120 && p.fontSize < 13).map((r) => `→ ${label(r)} — ${r.fontSize}px body text for a long passage; 14–16px reads better`));
|
|
348
|
+
capped(2, records
|
|
349
|
+
.filter((p) => p.textAlign === "justify" && p.textLen > 80)
|
|
350
|
+
.map((r) => `→ ${label(r)} — justified text produces uneven word rivers on the web; left-align`));
|
|
351
|
+
capped(2, records
|
|
352
|
+
.filter((p) => p.textTransform === "uppercase" && p.textLen > 30)
|
|
353
|
+
.map((r) => `→ ${label(r)} — ${r.textLen} chars of ALL-CAPS; caps suit short labels, hurt scanning at length`));
|
|
354
|
+
if (readability.length > 0)
|
|
355
|
+
sections.push(`READABILITY:\n` +
|
|
356
|
+
readability
|
|
357
|
+
.slice(0, 14)
|
|
358
|
+
.map((s) => ` ${s}`)
|
|
359
|
+
.join("\n"));
|
|
360
|
+
// ---- 4. SPACING scale: paddings AND vertical margins off a 4px grid suggest ad-hoc values.
|
|
361
|
+
const gridStats = (values) => {
|
|
362
|
+
const off = new Map();
|
|
363
|
+
let n = 0;
|
|
364
|
+
for (const v of values) {
|
|
365
|
+
if (v <= 0)
|
|
366
|
+
continue;
|
|
367
|
+
n += 1;
|
|
368
|
+
if (Math.abs(v - Math.round(v / 4) * 4) > 0.5)
|
|
369
|
+
off.set(v, (off.get(v) ?? 0) + 1);
|
|
370
|
+
}
|
|
371
|
+
const offTotal = [...off.values()].reduce((a, b) => a + b, 0);
|
|
372
|
+
return { pct: n > 0 ? Math.round((offTotal / n) * 100) : 0, top: [...off.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), n };
|
|
373
|
+
};
|
|
374
|
+
const pad = gridStats(records.flatMap((r) => r.padding));
|
|
375
|
+
const mar = gridStats(records.flatMap((r) => r.marginV));
|
|
376
|
+
const spacingLines = [];
|
|
377
|
+
if (pad.n > 10 && pad.pct > 20)
|
|
378
|
+
spacingLines.push(`${pad.pct}% of paddings are off a 4px grid — ad-hoc values: ${pad.top.map(([v, n]) => `${v}px×${n}`).join(", ")}`);
|
|
379
|
+
if (mar.n > 10 && mar.pct > 20)
|
|
380
|
+
spacingLines.push(`${mar.pct}% of vertical margins are off a 4px grid — ad-hoc values: ${mar.top.map(([v, n]) => `${v}px×${n}`).join(", ")}`);
|
|
381
|
+
if (spacingLines.length > 0)
|
|
382
|
+
sections.push(`SPACING: ` + spacingLines.join("\n "));
|
|
383
|
+
// ---- 5. Touch/click targets below ~24px are hard to hit. Inline links are
|
|
384
|
+
// exempt (mirrors the WCAG 2.2 target-size exception); worst offenders first.
|
|
385
|
+
const tiny = records.filter((r) => r.interactive && tooSmall(r)).sort((a, b) => a.rect.w * a.rect.h - b.rect.w * b.rect.h);
|
|
386
|
+
if (tiny.length > 0) {
|
|
387
|
+
sections.push(`TINY targets (${tiny.length}, smallest first):\n` +
|
|
388
|
+
tiny
|
|
389
|
+
.slice(0, 6)
|
|
390
|
+
.map((r) => ` ⚠ ${label(r)} — ${r.rect.w}×${r.rect.h}px`)
|
|
391
|
+
.join("\n"));
|
|
392
|
+
}
|
|
393
|
+
// ---- 6. Clipped text (overflow hidden without ellipsis) — content silently cut off.
|
|
394
|
+
const clipped = records.filter((r) => r.clipped);
|
|
395
|
+
if (clipped.length > 0) {
|
|
396
|
+
sections.push(`CLIPPED text (${clipped.length}):\n` +
|
|
397
|
+
clipped
|
|
398
|
+
.slice(0, 6)
|
|
399
|
+
.map((r) => ` ⚠ ${label(r)} — text wider than its box, no ellipsis`)
|
|
400
|
+
.join("\n"));
|
|
401
|
+
}
|
|
402
|
+
// ---- 7. Near-miss alignment: columns whose left edges differ by 1–4px look "off" without being nameable from a screenshot.
|
|
403
|
+
const xCounts = new Map();
|
|
404
|
+
for (const r of records) {
|
|
405
|
+
if (r.rect.x >= 0 && r.rect.x < viewport.width)
|
|
406
|
+
xCounts.set(r.rect.x, (xCounts.get(r.rect.x) ?? 0) + 1);
|
|
407
|
+
}
|
|
408
|
+
const columns = [...xCounts.entries()]
|
|
409
|
+
.filter(([, n]) => n >= 4)
|
|
410
|
+
.map(([x]) => x)
|
|
411
|
+
.sort((a, b) => a - b);
|
|
412
|
+
const nearMiss = [];
|
|
413
|
+
for (let i = 1; i < columns.length && nearMiss.length < 4; i++) {
|
|
414
|
+
const delta = columns[i] - columns[i - 1];
|
|
415
|
+
if (delta >= 1 && delta <= 4) {
|
|
416
|
+
nearMiss.push(`columns at x=${columns[i - 1]} (${xCounts.get(columns[i - 1])} els) vs x=${columns[i]} (${xCounts.get(columns[i])} els) — ${delta}px off`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (nearMiss.length > 0) {
|
|
420
|
+
sections.push(`ALIGNMENT near-misses:\n` + nearMiss.map((s) => ` ⚠ ${s}`).join("\n"));
|
|
421
|
+
}
|
|
422
|
+
// ---- 8. CONSISTENCY: one control system, one elevation system.
|
|
423
|
+
// Radii ≥ half the element height render as a pill regardless of raw value (9999px, Chromium's 16777200px clamp).
|
|
424
|
+
const consistency = [];
|
|
425
|
+
const radiusLabel = (r) => (r.radius >= r.rect.h / 2 && r.radius > 0 ? "pill" : `${r.radius}px`);
|
|
426
|
+
const buttons = records.filter((r) => r.interactive && r.tag === "button");
|
|
427
|
+
const buttonRadii = [...new Set(buttons.map(radiusLabel))];
|
|
428
|
+
if (buttonRadii.length > 3) {
|
|
429
|
+
consistency.push(`→ buttons use ${buttonRadii.length} different corner radii (${buttonRadii.slice(0, 6).join(", ")}) — pick one radius per control tier`);
|
|
430
|
+
}
|
|
431
|
+
const buttonHeights = [...new Set(buttons.filter((b) => b.rect.h >= 12 && b.rect.h <= 80).map((b) => Math.round(b.rect.h / 2) * 2))].sort((a, b) => a - b);
|
|
432
|
+
if (buttonHeights.length > 3) {
|
|
433
|
+
consistency.push(`→ buttons render at ${buttonHeights.length} different heights (${buttonHeights.slice(0, 8).join(", ")}px) — 1–2 control sizes read as a system`);
|
|
434
|
+
}
|
|
435
|
+
const shadows = new Map();
|
|
436
|
+
for (const r of records)
|
|
437
|
+
if (r.shadow)
|
|
438
|
+
shadows.set(r.shadow, (shadows.get(r.shadow) ?? 0) + 1);
|
|
439
|
+
if (shadows.size > 4) {
|
|
440
|
+
const top = [...shadows.entries()]
|
|
441
|
+
.sort((a, b) => b[1] - a[1])
|
|
442
|
+
.slice(0, 3)
|
|
443
|
+
.map(([s]) => `"${s.slice(0, 40)}"`);
|
|
444
|
+
consistency.push(`→ ${shadows.size} distinct box-shadow styles (${top.join(", ")}…) — an elevation system needs 2–3 levels, not one per component`);
|
|
445
|
+
}
|
|
446
|
+
if (consistency.length > 0)
|
|
447
|
+
sections.push(`CONSISTENCY:\n` + consistency.map((s) => ` ${s}`).join("\n"));
|
|
448
|
+
// ---- 9. PALETTE discipline: near-black beats #000; a gray SCALE beats ad-hoc grays; few accent hues beat many.
|
|
449
|
+
const palette = [];
|
|
450
|
+
const pureBlack = prose.filter((r) => r.color === "rgba(0, 0, 0, 1)" && (parseRgb(r.bg) ?? [0, 0, 0]).slice(0, 3).every((c) => c >= 250));
|
|
451
|
+
if (pureBlack.length > 0) {
|
|
452
|
+
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`);
|
|
453
|
+
}
|
|
454
|
+
const grayFreq = new Map();
|
|
455
|
+
const hueFamilies = new Map();
|
|
456
|
+
for (const r of records) {
|
|
457
|
+
for (const c of [r.color, r.bg]) {
|
|
458
|
+
const p = parseRgb(c);
|
|
459
|
+
if (!p)
|
|
460
|
+
continue;
|
|
461
|
+
const rgb = [p[0], p[1], p[2]];
|
|
462
|
+
const spread = Math.max(...rgb) - Math.min(...rgb);
|
|
463
|
+
const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
464
|
+
if (spread <= 10 && avg > 20 && avg < 245) {
|
|
465
|
+
const key = rgb.join(",");
|
|
466
|
+
grayFreq.set(key, (grayFreq.get(key) ?? 0) + 1);
|
|
467
|
+
}
|
|
468
|
+
const hue = hueOf(rgb);
|
|
469
|
+
if (hue !== null) {
|
|
470
|
+
const bucket = (Math.round(hue / 30) * 30) % 360;
|
|
471
|
+
hueFamilies.set(bucket, (hueFamilies.get(bucket) ?? 0) + 1);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (grayFreq.size > 6) {
|
|
476
|
+
const top = [...grayFreq.entries()]
|
|
477
|
+
.sort((a, b) => b[1] - a[1])
|
|
478
|
+
.slice(0, 5)
|
|
479
|
+
.map(([k]) => `rgb(${k})`);
|
|
480
|
+
palette.push(`→ ${grayFreq.size} distinct grays (${top.join(", ")}…) — a deliberate gray scale runs 4–6 steps; near-duplicates suggest ad-hoc values`);
|
|
481
|
+
}
|
|
482
|
+
if (hueFamilies.size > 5) {
|
|
483
|
+
const hues = [...hueFamilies.keys()].sort((a, b) => a - b);
|
|
484
|
+
palette.push(`→ ${hueFamilies.size} accent hue families (~${hues.join("°, ")}°) — focused palettes run 1–3 hues plus semantic status colors (charts are a legitimate exception)`);
|
|
485
|
+
}
|
|
486
|
+
if (palette.length > 0)
|
|
487
|
+
sections.push(`PALETTE:\n` + palette.map((s) => ` ${s}`).join("\n"));
|
|
488
|
+
// ---- 9b. AI-SLOP TELLS (impeccable.style's absolute bans): stylistic patterns
|
|
489
|
+
// so strongly associated with template/AI output that their presence
|
|
490
|
+
// reads as "nobody designed this". Counted, first offender named.
|
|
491
|
+
const slopTells = [];
|
|
492
|
+
const tell = (key, msg) => {
|
|
493
|
+
const hits = records.filter((r) => r[key]);
|
|
494
|
+
if (hits.length > 0)
|
|
495
|
+
slopTells.push(`→ ${msg} — ${hits.length}× (e.g. ${label(hits[0])})`);
|
|
496
|
+
};
|
|
497
|
+
tell("sideStripe", "side-stripe borders (colored border-left/right accent; rewrite with full borders, background tints, or nothing)");
|
|
498
|
+
tell("gradientText", "gradient text (background-clip:text over a gradient; use a solid color, emphasize via weight/size)");
|
|
499
|
+
tell("glass", "glassmorphism (decorative backdrop-filter blur; rare and purposeful, or not at all)");
|
|
500
|
+
tell("glow", "neon glow shadows (large-blur saturated box-shadow)");
|
|
501
|
+
tell("aiGradient", "violet/purple gradient backgrounds (the stock AI palette)");
|
|
502
|
+
// Identical card grids: ≥4 same-sized rounded boxes is the template look.
|
|
503
|
+
const cardKey = new Map();
|
|
504
|
+
for (const r of records) {
|
|
505
|
+
if (r.rect.w < 150 || r.rect.w > 520 || r.rect.h < 90 || r.rect.h > 520 || r.radius <= 0)
|
|
506
|
+
continue;
|
|
507
|
+
const key = `${Math.round(r.rect.w / 4) * 4}×${Math.round(r.rect.h / 4) * 4}`;
|
|
508
|
+
const arr = cardKey.get(key) ?? [];
|
|
509
|
+
arr.push(r);
|
|
510
|
+
cardKey.set(key, arr);
|
|
511
|
+
}
|
|
512
|
+
for (const [key, arr] of cardKey) {
|
|
513
|
+
if (arr.length >= 4 && slopTells.length < 8) {
|
|
514
|
+
slopTells.push(`→ ${arr.length} identical ${key}px cards (e.g. ${label(arr[0])}) — same-size card grids read as template output; vary sizes or drop the cards`);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (slopTells.length > 0)
|
|
519
|
+
sections.push(`AI-SLOP TELLS:\n` + slopTells.map((s) => ` ${s}`).join("\n"));
|
|
520
|
+
// ---- 10. STRUCTURE: heading hierarchy is the page's information architecture made visible.
|
|
521
|
+
const structure = [];
|
|
522
|
+
const h1s = page.headings.filter((h) => h.level === 1);
|
|
523
|
+
if (page.headings.length > 0 && h1s.length === 0)
|
|
524
|
+
structure.push(`→ no <h1> — the page has headings but no top-level anchor`);
|
|
525
|
+
if (h1s.length > 1)
|
|
526
|
+
structure.push(`→ ${h1s.length} <h1> elements — one page, one primary heading`);
|
|
527
|
+
const levelsUsed = [...new Set(page.headings.map((h) => h.level))].sort((a, b) => a - b);
|
|
528
|
+
for (let i = 1; i < levelsUsed.length; i++) {
|
|
529
|
+
if (levelsUsed[i] - levelsUsed[i - 1] > 1)
|
|
530
|
+
structure.push(`→ heading levels skip h${levelsUsed[i - 1]}→h${levelsUsed[i]} — screen-reader outlines lose a level`);
|
|
531
|
+
}
|
|
532
|
+
const avgSize = new Map();
|
|
533
|
+
for (const lvl of levelsUsed) {
|
|
534
|
+
const of = page.headings.filter((h) => h.level === lvl);
|
|
535
|
+
avgSize.set(lvl, of.reduce((a, h) => a + h.size, 0) / of.length);
|
|
536
|
+
}
|
|
537
|
+
for (let i = 1; i < levelsUsed.length; i++) {
|
|
538
|
+
const [hi, lo] = [levelsUsed[i - 1], levelsUsed[i]];
|
|
539
|
+
if ((avgSize.get(lo) ?? 0) > (avgSize.get(hi) ?? 0) + 1) {
|
|
540
|
+
structure.push(`→ h${lo} renders larger than h${hi} (${Math.round(avgSize.get(lo))}px vs ${Math.round(avgSize.get(hi))}px) — visual hierarchy contradicts the semantic one`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (structure.length > 0)
|
|
544
|
+
sections.push(`STRUCTURE:\n` + structure.map((s) => ` ${s}`).join("\n"));
|
|
545
|
+
// ---- 11. AFFORDANCES: keyboard focus visibility (trusted-Tab sampled engine-side) + indistinguishable links.
|
|
546
|
+
const affordances = [];
|
|
547
|
+
const focusless = page.focusSamples.filter((f) => !f.indicator);
|
|
548
|
+
if (focusless.length > 0) {
|
|
549
|
+
affordances.push(`⚠ ${focusless.length}/${page.focusSamples.length} keyboard tab stops show NO visible focus indicator (outline/shadow/border unchanged on focus): ${focusless
|
|
550
|
+
.slice(0, 6)
|
|
551
|
+
.map((f) => f.label)
|
|
552
|
+
.join(", ")}${focusless.length > 6 ? " …" : ""}`);
|
|
553
|
+
}
|
|
554
|
+
const bodyColorFreq = new Map();
|
|
555
|
+
for (const r of records)
|
|
556
|
+
if (r.textLen > 40 && r.tag !== "a" && r.color !== "unknown")
|
|
557
|
+
bodyColorFreq.set(r.color, (bodyColorFreq.get(r.color) ?? 0) + 1);
|
|
558
|
+
const dominantBody = [...bodyColorFreq.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
559
|
+
if (dominantBody) {
|
|
560
|
+
const indistinct = records.filter((r) => r.tag === "a" && r.textLen > 0 && !r.underline && r.color === dominantBody);
|
|
561
|
+
if (indistinct.length > 0) {
|
|
562
|
+
affordances.push(`→ ${indistinct.length} link(s) with no underline AND the same color as body text (e.g. ${label(indistinct[0])}) — invisible as links`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (affordances.length > 0)
|
|
566
|
+
sections.push(`AFFORDANCES:\n` + affordances.map((s) => ` ${s}`).join("\n"));
|
|
567
|
+
// ---- 12. IMAGES: aspect-ratio distortion (rendered box fights the source's proportions).
|
|
568
|
+
const distorted = page.images.map((img) => ({ img, off: Math.abs(img.rw / img.rh / (img.nw / img.nh) - 1) })).filter(({ off }) => off > 0.12);
|
|
569
|
+
if (distorted.length > 0) {
|
|
570
|
+
sections.push(`IMAGES (${distorted.length} distorted):\n` +
|
|
571
|
+
distorted
|
|
572
|
+
.slice(0, 5)
|
|
573
|
+
.map(({ img, off }) => ` ⚠ ${img.label} — rendered ${img.rw}×${img.rh} vs natural ${img.nw}×${img.nh} (aspect off by ${Math.round(off * 100)}%; use object-fit)`)
|
|
574
|
+
.join("\n"));
|
|
575
|
+
}
|
|
576
|
+
// ---- 13. LAYOUT: horizontal overflow + fixed chrome share. Sticky bars ride
|
|
577
|
+
// along as the user scrolls; past ~1/4 of the viewport they turn every
|
|
578
|
+
// screenful of content into a letterbox.
|
|
579
|
+
const layout = [];
|
|
580
|
+
if (page.scrollW > viewport.width + 8) {
|
|
581
|
+
layout.push(`⚠ page scrolls horizontally — content ${page.scrollW}px vs ${viewport.width}px viewport (a responsive break at this width)`);
|
|
582
|
+
}
|
|
583
|
+
// Only bands currently on screen count (a sticky header far down a list is
|
|
584
|
+
// not pinned), and overlapping bars within one band count once (max, not
|
|
585
|
+
// sum) — otherwise ten section headers "occupy" a third of a viewport they
|
|
586
|
+
// never actually share.
|
|
587
|
+
// Over allRecords, not the page partition: this rule's entire subject is the
|
|
588
|
+
// fixed shell, so measuring it against records with the shell removed made it
|
|
589
|
+
// structurally unable to fire once the chrome census warmed up.
|
|
590
|
+
const chromeBands = new Map();
|
|
591
|
+
for (const r of allRecords) {
|
|
592
|
+
if (!r.fixed || r.rect.w < viewport.width * 0.6 || r.rect.h < 24 || r.rect.h > viewport.height * 0.6)
|
|
593
|
+
continue;
|
|
594
|
+
if (r.rect.y >= viewport.height || r.rect.y + r.rect.h <= 0)
|
|
595
|
+
continue;
|
|
596
|
+
const band = Math.round(r.rect.y / 40) * 40;
|
|
597
|
+
chromeBands.set(band, Math.max(chromeBands.get(band) ?? 0, r.rect.h));
|
|
598
|
+
}
|
|
599
|
+
const chromeH = [...chromeBands.values()].reduce((a, b) => a + b, 0);
|
|
600
|
+
if (chromeH > viewport.height * 0.25) {
|
|
601
|
+
layout.push(`→ fixed/sticky chrome occupies ~${Math.min(100, Math.round((chromeH / viewport.height) * 100))}% of the viewport (${Math.round(chromeH)}px of bars that follow every scroll) — content gets a letterbox; consider collapsing chrome on scroll`);
|
|
602
|
+
}
|
|
603
|
+
if (layout.length > 0)
|
|
604
|
+
sections.push(`LAYOUT:\n` + layout.map((l) => ` ${l}`).join("\n"));
|
|
605
|
+
// ---- 14. TASK EFFICIENCY: not "does it work" but "is it easy" — can a user
|
|
606
|
+
// see what to do, find it without scrolling, and finish without being
|
|
607
|
+
// over-asked? These are the journey-level questions a pass/fail e2e
|
|
608
|
+
// suite never answers.
|
|
609
|
+
const effort = [];
|
|
610
|
+
const pageBg = parseRgb(records.find((r) => r.bg && r.bg.startsWith("rgb"))?.bg ?? "") ?? [255, 255, 255, 1];
|
|
611
|
+
// A "prominent" action = filled control whose background clearly departs
|
|
612
|
+
// from the page background, at a clickable size. That is what the eye lands
|
|
613
|
+
// on, so it is the page's implied primary action.
|
|
614
|
+
const prominent = records.filter((r) => {
|
|
615
|
+
if (!r.interactive || r.rect.w < 60 || r.rect.h < 24)
|
|
616
|
+
return false;
|
|
617
|
+
const bg = parseRgb(r.bg);
|
|
618
|
+
if (!bg)
|
|
619
|
+
return false;
|
|
620
|
+
const delta = Math.abs(bg[0] - pageBg[0]) + Math.abs(bg[1] - pageBg[1]) + Math.abs(bg[2] - pageBg[2]);
|
|
621
|
+
return delta > 90;
|
|
622
|
+
});
|
|
623
|
+
const foldH = viewport.height;
|
|
624
|
+
if (prominent.length === 0) {
|
|
625
|
+
effort.push(`→ no visually dominant action on this page — nothing is filled/coloured enough to read as "the next step"; a user must read every control to decide what to do`);
|
|
626
|
+
}
|
|
627
|
+
else if (prominent.length > 3) {
|
|
628
|
+
effort.push(`→ ${prominent.length} equally-prominent actions compete for attention (${prominent
|
|
629
|
+
.slice(0, 4)
|
|
630
|
+
.map((r) => label(r))
|
|
631
|
+
.join(", ")}…) — when everything is emphasised nothing is; demote secondary actions to outline/text style`);
|
|
632
|
+
}
|
|
633
|
+
const aboveFold = prominent.filter((r) => r.rect.y >= 0 && r.rect.y < foldH);
|
|
634
|
+
if (prominent.length > 0 && aboveFold.length === 0) {
|
|
635
|
+
const nearest = prominent.reduce((a, b) => (a.rect.y < b.rect.y ? a : b));
|
|
636
|
+
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`);
|
|
637
|
+
}
|
|
638
|
+
// Form burden: how much is being asked, and how much of it is actually needed.
|
|
639
|
+
const fields = records.filter((r) => r.interactive && /input|select|textarea/.test(r.tag));
|
|
640
|
+
if (fields.length >= 5) {
|
|
641
|
+
const req = fields.filter((r) => r.required).length;
|
|
642
|
+
if (req === 0) {
|
|
643
|
+
effort.push(`→ ${fields.length} form fields and NONE marked required (no required/aria-required) — the user cannot tell what is actually needed to finish, so the form reads as ${fields.length} obligations instead of the few that matter`);
|
|
644
|
+
}
|
|
645
|
+
else if (fields.length - req >= 8) {
|
|
646
|
+
effort.push(`→ ${fields.length} fields of which only ${req} are required — ${fields.length - req} optional fields are shown up-front; consider progressive disclosure ("add details" / a second step) so the required path is short`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Information scent: does the page say what it is and what to do?
|
|
650
|
+
const hasHeading = page.headings.length > 0;
|
|
651
|
+
const submits = records.filter((r) => r.submitish && r.interactive);
|
|
652
|
+
if (!hasHeading && records.length > 20) {
|
|
653
|
+
effort.push(`→ no heading element on a content-bearing page — nothing states what this screen is, which hurts orientation and screen-reader navigation alike`);
|
|
654
|
+
}
|
|
655
|
+
if (fields.length >= 3 && submits.length === 0) {
|
|
656
|
+
effort.push(`→ ${fields.length} input fields but no obvious submit/confirm control detected — a user filling this in has no clear way to commit it`);
|
|
657
|
+
}
|
|
658
|
+
if (effort.length > 0)
|
|
659
|
+
sections.push(`TASK EFFICIENCY:\n` + effort.map((s) => ` ${s}`).join("\n"));
|
|
660
|
+
// ---- Always-on coherence summary: the design system at a glance, with healthy ranges.
|
|
661
|
+
const gridPct = pad.n > 0 ? 100 - pad.pct : 100;
|
|
662
|
+
const summary = `SYSTEM SUMMARY: ${sizes.length} font sizes (≤7 healthy) · ${families.length} families (≤2) · ` +
|
|
663
|
+
`${buttonRadii.length} button radii (1–2) · ${buttonHeights.length} button heights (1–3) · ${shadows.size} shadow styles (≤3) · ` +
|
|
664
|
+
`${grayFreq.size} grays (4–6) · ${hueFamilies.size} accent hue families (1–3 + status) · ${gridPct}% spacing on 4px grid · ` +
|
|
665
|
+
`${page.density} interactive elements in first viewport${page.density > 40 ? " (dense — consider progressive disclosure)" : ""}`;
|
|
666
|
+
// ---- Multi-indicator PAGE SCORE. Deductive: start at 100 per dimension,
|
|
667
|
+
// subtract per measured issue, floor at 0. Weights favour a11y and
|
|
668
|
+
// task clarity — a page can be pretty and still hard to use.
|
|
669
|
+
const floor0 = (n) => Math.max(0, Math.round(n));
|
|
670
|
+
const a11yScore = floor0(100 - contrastFails.length * 4 - focusless.length * 6 - tiny.length * 3);
|
|
671
|
+
const craftScore = floor0(100 - readability.length * 4 - palette.length * 4 - slopTells.length * 5 - clipped.length * 3 - distorted.length * 4);
|
|
672
|
+
const consistencyScore = floor0(100 -
|
|
673
|
+
consistency.length * 8 -
|
|
674
|
+
(pad.n > 10 && pad.pct > 20 ? 10 : 0) -
|
|
675
|
+
(mar.n > 10 && mar.pct > 20 ? 6 : 0) -
|
|
676
|
+
nearMiss.length * 3 -
|
|
677
|
+
(sizes.length > 7 ? 5 : 0) -
|
|
678
|
+
(nearDupSizes.length > 0 ? 5 : 0));
|
|
679
|
+
const clarityScore = floor0(100 - effort.length * 8 - (page.scrollW > viewport.width + 8 ? 15 : 0) - structure.length * 4);
|
|
680
|
+
const overall = Math.round(a11yScore * 0.3 + craftScore * 0.25 + consistencyScore * 0.2 + clarityScore * 0.25);
|
|
681
|
+
const grade = overall >= 90 ? "A" : overall >= 80 ? "B" : overall >= 70 ? "C" : overall >= 60 ? "D" : "E";
|
|
682
|
+
const score = { overall, a11y: a11yScore, craft: craftScore, consistency: consistencyScore, clarity: clarityScore };
|
|
683
|
+
const scoreLine = `PAGE SCORE: ${overall}/100 (${grade}) — a11y ${a11yScore} · craft ${craftScore} · consistency ${consistencyScore} · task-clarity ${clarityScore}`;
|
|
684
|
+
// Shared shell, reported separately and NOT scored into this page. Contrast
|
|
685
|
+
// is the only rule worth restating here: it is the one that generated the
|
|
686
|
+
// duplicate findings, and the fix lives in one stylesheet rather than on
|
|
687
|
+
// whichever page happened to be audited when it was noticed.
|
|
688
|
+
// Chrome is excluded from the page's SCORE, but its defects must still be
|
|
689
|
+
// reported or the partition would silently delete whole rule classes for the
|
|
690
|
+
// shell — sub-minimum tap targets and clipped labels in a sidebar would be
|
|
691
|
+
// seen by no page at all.
|
|
692
|
+
const chromeSection = [];
|
|
693
|
+
if (chromeRecords.length > 0) {
|
|
694
|
+
const chromeIssues = [
|
|
695
|
+
...contrastFailures(chromeRecords),
|
|
696
|
+
...chromeRecords.filter((r) => r.interactive && tooSmall(r)).map((r) => `${label(r)} — ${Math.round(r.rect.w)}×${Math.round(r.rect.h)}px tap target`),
|
|
697
|
+
...chromeRecords.filter((r) => r.clipped && r.textLen > 0).map((r) => `${label(r)} — text is clipped by its container`),
|
|
698
|
+
];
|
|
699
|
+
if (chromeIssues.length > 0) {
|
|
700
|
+
chromeSection.push(`SHARED CHROME (${chromeRecords.length} shell elements, excluded from this page's score and reported here instead):\n` +
|
|
701
|
+
[...new Set(chromeIssues)]
|
|
702
|
+
.slice(0, CHROME_ISSUE_CAP)
|
|
703
|
+
.map((s) => ` ⚠ ${s}`)
|
|
704
|
+
.join("\n") +
|
|
705
|
+
`\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.`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const header = `DESIGN AUDIT (${records.length} page elements sampled` +
|
|
709
|
+
`${chromeRecords.length > 0 ? `, ${chromeRecords.length} shared-chrome elements excluded from the score` : ""}): ` +
|
|
710
|
+
`${sections.length === 0 ? "no measurable issues — and see the system summary below." : `${sections.length} issue group(s).`}`;
|
|
711
|
+
const report = [header, ...sections, ...chromeSection, summary, scoreLine].join("\n\n") +
|
|
712
|
+
`\n\nJudge with product context: ⚠ lines are measurable defects; → lines are craft suggestions (how the page could be BETTER, not just what's broken). ` +
|
|
713
|
+
`Not every flag is a bug — dense data tables legitimately use small targets. The score is a comparator across pages and runs, not an absolute verdict. ` +
|
|
714
|
+
`File real defects with scout_finding (category "visual"/"a11y") and genuine improvement opportunities as severity-low "ux-polish", quoting the concrete numbers.`;
|
|
715
|
+
return { report, score, signatures };
|
|
716
|
+
}
|