@gessobuild/anti-slop 0.4.2

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/dist/rules.js ADDED
@@ -0,0 +1,4046 @@
1
+ // Anti-slop: the flagship rule registry.
2
+ //
3
+ // A curated subset of the rules battle-tested inside Gesso's generation
4
+ // pipeline: universal generated-UI tells that hold on any HTML/CSS, with
5
+ // deterministic, idempotent fixes. Token references are neutral here
6
+ // (currentColor / plain rgba fallbacks); hosts with a design system can
7
+ // swap fix targets by shipping their own rules alongside these.
8
+ //
9
+ // Most rules carry a deterministic fix; a few are detect-only (tier "gate")
10
+ // because no regex rewrite could be design-preserving (naming the right
11
+ // transition properties, writing real copy, choosing a real image).
12
+ //
13
+ // Escape hatch for a deliberately "slop-shaped" element:
14
+ // - element rules: a `data-slop-allow="rule-id ..."` attribute,
15
+ // - CSS-group rules: a `--slop-allow: rule-id ...` custom property in the
16
+ // same declaration block. Value is a space/comma list of ids, or "all".
17
+ import { parse } from "node-html-parser";
18
+ // ---------------------------------------------------------------------------
19
+ // Shared helpers
20
+ // ---------------------------------------------------------------------------
21
+ const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b(?:white|black|red|blue|green|orange|purple|pink|yellow|cyan|magenta|gray|grey|navy|teal|gold|crimson|coral|indigo|violet|slate|amber|emerald|rose|sky|lime)\b/;
22
+ function firstColor(s) {
23
+ const m = s.match(COLOR_RE);
24
+ return m ? m[0] : null;
25
+ }
26
+ /** Visible text length of an element's inner HTML (tags + runs collapsed). */
27
+ function visibleLen(inner) {
28
+ return inner
29
+ .replace(/<[^>]+>/g, "")
30
+ .replace(/&[a-z]+;/gi, "x")
31
+ .replace(/\s+/g, " ")
32
+ .trim().length;
33
+ }
34
+ /** Does a slop-allow value list cover this rule id (or "all")? */
35
+ function allowListHas(raw, ruleId) {
36
+ if (!raw)
37
+ return false;
38
+ const tokens = raw.toLowerCase().split(/[\s,]+/).filter(Boolean);
39
+ return tokens.includes("all") || tokens.includes(ruleId);
40
+ }
41
+ /** CSS-group opt-out: a `--slop-allow: ...` custom property inside the decls. */
42
+ function declsAllow(decls, ruleId) {
43
+ const m = decls.match(/--slop-allow\s*:\s*([^;}]+)/i);
44
+ return allowListHas(m?.[1] ?? null, ruleId);
45
+ }
46
+ /** Element opt-out: a `data-slop-allow="..."` attribute inside a tag string. */
47
+ function tagAllows(tagOrAttrs, ruleId) {
48
+ const m = tagOrAttrs.match(/\bdata-slop-allow\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
49
+ return allowListHas(m?.[1] ?? m?.[2] ?? null, ruleId);
50
+ }
51
+ // Strip CSS block comments so a literal `}` or `;` inside `/* ... */` can't
52
+ // defeat the brace/semicolon splitters.
53
+ function stripCssComments(css) {
54
+ return css.replace(/\/\*[\s\S]*?\*\//g, "");
55
+ }
56
+ /** Count <style> rule bodies + inline style="" values matching `predicate`. */
57
+ function eachStyleAndInline(html, predicate) {
58
+ let n = 0;
59
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
60
+ for (const rule of stripCssComments(block[1]).matchAll(/[^{}]+\{([^{}]*)\}/g)) {
61
+ if (predicate(rule[1]))
62
+ n++;
63
+ }
64
+ }
65
+ for (const inline of html.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi)) {
66
+ if (predicate(inline[1]))
67
+ n++;
68
+ }
69
+ return n;
70
+ }
71
+ /**
72
+ * Rewrite every <style> rule body + inline style="" value whose declarations
73
+ * match `predicate`, via `transform`.
74
+ */
75
+ function rewriteCssGroups(html, predicate, transform) {
76
+ let out = html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (full, css) => {
77
+ const clean = stripCssComments(css);
78
+ const fixed = clean.replace(/([^{}]+)\{([^{}]*)\}/g, (rule, sel, body) => predicate(body) ? `${sel}{${transform(body)}}` : rule);
79
+ return fixed === clean ? full : full.replace(css, () => fixed);
80
+ });
81
+ out = out.replace(/\sstyle\s*=\s*"([^"]*)"/gi, (full, val) => predicate(val) ? ` style="${transform(val)}"` : full);
82
+ return out;
83
+ }
84
+ /** Map `fn` over text-node content only, skipping <style>/<script>/comments. */
85
+ const PROTECTED_SPAN_RE = /<style\b[\s\S]*?<\/style>|<script\b[\s\S]*?<\/script>|<!--[\s\S]*?-->/gi;
86
+ function eachVisibleText(html, fn) {
87
+ const transform = (chunk) => chunk.replace(/>([^<]+)</g, (_full, text) => `>${fn(text)}<`);
88
+ let out = "";
89
+ let last = 0;
90
+ for (const m of html.matchAll(PROTECTED_SPAN_RE)) {
91
+ out += transform(html.slice(last, m.index ?? 0)) + m[0];
92
+ last = (m.index ?? 0) + m[0].length;
93
+ }
94
+ return out + transform(html.slice(last));
95
+ }
96
+ /** Apply `fn` only to markup OUTSIDE script/style/comment regions. */
97
+ const RAW_REGION_RE = /<script\b[^>]*>[\s\S]*?<\/script>|<style\b[^>]*>[\s\S]*?<\/style>|<!--[\s\S]*?-->/gi;
98
+ function outsideRawRegions(html, fn) {
99
+ let out = "";
100
+ let last = 0;
101
+ for (const m of html.matchAll(RAW_REGION_RE)) {
102
+ out += fn(html.slice(last, m.index)) + m[0];
103
+ last = m.index + m[0].length;
104
+ }
105
+ return out + fn(html.slice(last));
106
+ }
107
+ // ---- gradient-text ---------------------------------------------------------
108
+ const GRADIENT_FN_RE = /(?:repeating-)?(?:linear|radial|conic)-gradient\s*\([^()]*(?:\([^()]*\)[^()]*)*\)/i;
109
+ function isGradientTextGroup(decls) {
110
+ if (declsAllow(decls, "gradient-text"))
111
+ return false; // intentional opt-out
112
+ const clipsToText = /(?:^|[\s;{])(?:-webkit-)?background-clip\s*:\s*text/i.test(decls);
113
+ const transparentFill = /(?:^|[\s;{])(?:color|(?:-webkit-)?text-fill-color|fill)\s*:\s*transparent/i.test(decls);
114
+ return clipsToText && transparentFill;
115
+ }
116
+ function fixGradientTextGroup(decls) {
117
+ const grad = decls.match(GRADIENT_FN_RE)?.[0] ?? "";
118
+ const stop = (grad && firstColor(grad)) || "currentColor";
119
+ return decls
120
+ .split(";")
121
+ .map((d) => d.trim())
122
+ .filter(Boolean)
123
+ .filter((d) => {
124
+ if (/^(?:-webkit-)?background-clip\s*:\s*text$/i.test(d))
125
+ return false;
126
+ if (/^background(?:-image)?\s*:/i.test(d) && GRADIENT_FN_RE.test(d))
127
+ return false;
128
+ return true;
129
+ })
130
+ .map((d) => /^(?:color|(?:-webkit-)?text-fill-color|fill)\s*:\s*transparent$/i.test(d)
131
+ ? `${d.split(":")[0].trim()}: ${stop}`
132
+ : d)
133
+ .join("; ");
134
+ }
135
+ // ---- hollow-text -----------------------------------------------------------
136
+ function isHollowTextGroup(decls) {
137
+ const hasStroke = /(?:^|[\s;{])(?:-webkit-)?text-stroke(?:-width|-color)?\s*:/i.test(decls);
138
+ const transparentFill = /(?:^|[\s;{])(?:color|(?:-webkit-)?text-fill-color|fill)\s*:\s*transparent/i.test(decls);
139
+ return hasStroke && transparentFill && !isGradientTextGroup(decls);
140
+ }
141
+ function fixHollowTextGroup(decls) {
142
+ return decls
143
+ .split(";")
144
+ .map((d) => d.trim())
145
+ .filter(Boolean)
146
+ .filter((d) => !/^(?:-webkit-)?text-stroke(?:-width|-color)?\s*:/i.test(d))
147
+ .map((d) => /^(?:color|(?:-webkit-)?text-fill-color|fill)\s*:\s*transparent$/i.test(d)
148
+ ? `${d.split(":")[0].trim()}: currentColor`
149
+ : d)
150
+ .join("; ");
151
+ }
152
+ // ---- indigo-accent ---------------------------------------------------------
153
+ const INDIGO_HEX_SRC = "#(?:6366f1|818cf8|4f46e5|4338ca|3730a3|8b5cf6|7c3aed|6d28d9|a78bfa|5b21b6)\\b";
154
+ const indigoHexRe = () => new RegExp(INDIGO_HEX_SRC, "gi");
155
+ function declHasIndigo(decl) {
156
+ const ci = decl.indexOf(":");
157
+ if (ci < 0)
158
+ return false;
159
+ const prop = decl.slice(0, ci).trim().toLowerCase();
160
+ // Skip custom-property DEFINITIONS (rewriting --accent:#6366f1 to
161
+ // var(--accent) would self-reference) and `content` literals; skip values
162
+ // with url(...) (an indigo hex there is an SVG fragment id).
163
+ if (prop.startsWith("--") || prop === "content")
164
+ return false;
165
+ const val = decl.slice(ci + 1);
166
+ if (/url\(/i.test(val))
167
+ return false;
168
+ return indigoHexRe().test(val);
169
+ }
170
+ function groupHasIndigo(decls) {
171
+ return decls.split(";").some((d) => declHasIndigo(d.trim()));
172
+ }
173
+ function fixIndigoGroup(decls) {
174
+ return decls
175
+ .split(";")
176
+ .map((d) => {
177
+ const t = d.trim();
178
+ return declHasIndigo(t)
179
+ ? t.replace(indigoHexRe(), "var(--accent, currentColor)")
180
+ : t;
181
+ })
182
+ .filter(Boolean)
183
+ .join("; ");
184
+ }
185
+ // ---- heavy-box-shadow ------------------------------------------------------
186
+ const SHADOW_FLATTEN_TARGET = "0 1px 2px rgba(0,0,0,0.06)";
187
+ function splitTopLevelCommas(value) {
188
+ const parts = [];
189
+ let depth = 0;
190
+ let cur = "";
191
+ for (const ch of value) {
192
+ if (ch === "(")
193
+ depth++;
194
+ else if (ch === ")")
195
+ depth = Math.max(0, depth - 1);
196
+ if (ch === "," && depth === 0) {
197
+ parts.push(cur);
198
+ cur = "";
199
+ }
200
+ else
201
+ cur += ch;
202
+ }
203
+ if (cur.trim())
204
+ parts.push(cur);
205
+ return parts;
206
+ }
207
+ function parseAlpha(raw) {
208
+ const t = raw.trim();
209
+ const n = parseFloat(t);
210
+ if (!Number.isFinite(n))
211
+ return 1;
212
+ return t.endsWith("%") ? n / 100 : n;
213
+ }
214
+ function shadowLayerAlpha(layer) {
215
+ const fn = layer.match(/(?:rgba?|hsla?)\(([^)]*)\)/i);
216
+ if (fn) {
217
+ const inner = fn[1].trim();
218
+ const slash = inner.split("/");
219
+ if (slash.length === 2)
220
+ return parseAlpha(slash[1]);
221
+ const parts = inner.split(",").map((s) => s.trim()).filter(Boolean);
222
+ return parts.length >= 4 ? parseAlpha(parts[3]) : 1;
223
+ }
224
+ const hex8 = layer.match(/#[0-9a-f]{8}\b/i);
225
+ if (hex8)
226
+ return parseInt(hex8[0].slice(7, 9), 16) / 255;
227
+ return 1;
228
+ }
229
+ function shadowLayerLengths(layer) {
230
+ const stripped = layer
231
+ .replace(/(?:rgba?|hsla?)\([^)]*\)/gi, " ")
232
+ .replace(/var\([^)]*\)/gi, " ")
233
+ .replace(/#[0-9a-f]+/gi, " ");
234
+ return (stripped.match(/-?\d*\.?\d+/g) ?? []).map(Number).filter(Number.isFinite);
235
+ }
236
+ /** Is this box-shadow VALUE slop: >=3 stacked layers, 2 layers heavier than a
237
+ * subtle Material pair, or one blurred layer at alpha > 0.30? Excludes inset,
238
+ * hard-offset (blur 0), and token vars. */
239
+ function boxShadowIsSlop(value) {
240
+ const v = value.trim();
241
+ if (!v || /^none$/i.test(v))
242
+ return false;
243
+ if (/var\(\s*--[a-z-]*shadow/i.test(v))
244
+ return false; // already a token
245
+ let outerBlurred = 0;
246
+ let maxOuterAlpha = 0;
247
+ for (const layer of splitTopLevelCommas(v)) {
248
+ if (/\binset\b/i.test(layer))
249
+ continue;
250
+ const lengths = shadowLayerLengths(layer);
251
+ const blur = lengths.length >= 3 ? lengths[2] : 0;
252
+ if (blur > 0) {
253
+ outerBlurred++;
254
+ maxOuterAlpha = Math.max(maxOuterAlpha, shadowLayerAlpha(layer));
255
+ }
256
+ }
257
+ if (outerBlurred >= 3)
258
+ return true;
259
+ if (outerBlurred >= 2 && maxOuterAlpha > 0.12)
260
+ return true;
261
+ if (outerBlurred === 1 && maxOuterAlpha > 0.3)
262
+ return true;
263
+ return false;
264
+ }
265
+ const boxShadowDeclRe = () => /((?:^|[;{\s])(?:-webkit-|-moz-)?box-shadow\s*:\s*)([^;}]+)/gi;
266
+ function groupHasSlopShadow(decls) {
267
+ for (const m of decls.matchAll(boxShadowDeclRe())) {
268
+ if (boxShadowIsSlop(m[2]))
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+ function flattenSlopShadows(decls) {
274
+ return decls.replace(boxShadowDeclRe(), (full, pre, val) => boxShadowIsSlop(val) ? `${pre}${SHADOW_FLATTEN_TARGET}` : full);
275
+ }
276
+ // ---- gradient-border -------------------------------------------------------
277
+ const GRADIENT_BORDER_RE = /border-image(?:-source)?\s*:\s*[^;"}']*(?:linear|radial|conic)-gradient[^;"}']*/gi;
278
+ const GRADIENT_BORDER_STRIP_RE = /\s*border-image(?:-source)?\s*:\s*[^;"}']*(?:linear|radial|conic)-gradient[^;"}']*;?/gi;
279
+ // ---- decorative-divider ----------------------------------------------------
280
+ // Box-drawing runs (U+2500-2570, U+2574-257F; diagonals spared) and em/en
281
+ // dash runs, written as unicode escapes so the source stays dash-free.
282
+ const BOXRUN_RE = /[\u2500-\u2570\u2574-\u257F]{2,}/g;
283
+ const DASHRUN_RE = /[\u2014\u2013]{2,}/g;
284
+ function detectDecorativeDividers(html) {
285
+ const hits = [];
286
+ eachVisibleText(html, (text) => {
287
+ for (const m of text.matchAll(BOXRUN_RE))
288
+ hits.push({ ruleId: "decorative-divider", detail: `box-drawing "${m[0].slice(0, 8)}"` });
289
+ for (const m of text.matchAll(DASHRUN_RE))
290
+ hits.push({ ruleId: "decorative-divider", detail: `dash run "${m[0].slice(0, 8)}"` });
291
+ return text; // detect-only: never mutate
292
+ });
293
+ return hits;
294
+ }
295
+ // ---- broken-image ----------------------------------------------------------
296
+ const IMG_TAG_RE = /<img\b[^>]*>/gi;
297
+ /** Photo-pipeline slots a host resolver fills post-generation (sanctioned). */
298
+ const RESOLVER_SLOT_RE = /\bdata-photo-query\b|\bdata-photo-placeholder\b|\bdata-illustration\b|\bdata-attachment-ref\b/i;
299
+ function isBrokenImg(tag) {
300
+ // Photo-pipeline slots intentionally ship without a src (a host resolver
301
+ // fills them post-generation); treat the marker attributes as sanctioned.
302
+ // data-attachment-ref is the same: a placed/pending user asset a host
303
+ // resolver fills with the stored URL, so a src-less one is not broken.
304
+ if (RESOLVER_SLOT_RE.test(tag)) {
305
+ return false;
306
+ }
307
+ if (tagAllows(tag, "broken-image"))
308
+ return false;
309
+ const srcM = tag.match(/\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
310
+ if (!srcM)
311
+ return true;
312
+ const raw = (srcM[1] ?? srcM[2] ?? srcM[3] ?? "").trim();
313
+ if (raw === "")
314
+ return true;
315
+ const low = raw.toLowerCase();
316
+ if (["#", "about:blank", "undefined", "null", "todo"].includes(low))
317
+ return true;
318
+ if (/^\{\{.*\}\}$/.test(raw))
319
+ return true; // {{template}} placeholders
320
+ if (/^(?:placeholder|your-image-here)\b/.test(low))
321
+ return true;
322
+ if (/path\/to\//.test(low))
323
+ return true;
324
+ if (/example\.com\/(?:placeholder|img|image)/.test(low))
325
+ return true;
326
+ return false;
327
+ }
328
+ // ---- emoji-icon ------------------------------------------------------------
329
+ const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{1F000}-\\u{1F0FF}\\u{1F1E6}-\\u{1F1FF}]";
330
+ const EMOJI_MOD = "[\\uFE0F\\u200D\\u20E3]";
331
+ const emojiIconRe = () => new RegExp(`(<(?:a|button|h[1-6]|span|li|dt|dd|figcaption|label|strong|b|small)\\b[^>]*>)\\s*(?:${EMOJI_MOD}*${EMOJI_BASE}${EMOJI_MOD}*)+\\s*(?=[\\p{L}\\p{N}])`, "giu");
332
+ // ---- cents-suffix ----------------------------------------------------------
333
+ const PRICE_PREFIX = "(?:[$£€¥]\\s?\\d[\\d,]*|\\d{1,3}(?:,\\d{3})+)";
334
+ const centsRe = () => new RegExp(`(${PRICE_PREFIX})\\.?\\s*<span\\b[^>]*>\\s*\\.?\\d{1,2}\\s*</span>`, "gi");
335
+ function fixCents(html) {
336
+ let prev = "";
337
+ let out = html;
338
+ for (let i = 0; i < 8 && out !== prev; i++) {
339
+ prev = out;
340
+ out = out.replace(centsRe(), "$1");
341
+ }
342
+ return out;
343
+ }
344
+ // ---- oversized-number ------------------------------------------------------
345
+ const OVERSIZED_NUM_THRESHOLD = 10_000;
346
+ const OVERSIZED_NUM_RE = /(?<![\w.])([$£€¥₹]?)(\d{1,3}(?:,\d{3})+|\d{5,})(\.\d+)?(?![\d,]*\.?\d*\s*[%kKmMbB])/g;
347
+ const ABBR_TIERS = [
348
+ { v: 1e9, s: "B" },
349
+ { v: 1e6, s: "M" },
350
+ { v: 1e3, s: "K" },
351
+ ];
352
+ function oversizedValue(intPart, decPart) {
353
+ return parseFloat(intPart.replace(/,/g, "") + (decPart ?? ""));
354
+ }
355
+ function abbreviateMagnitude(value) {
356
+ for (const { v, s } of ABBR_TIERS) {
357
+ if (value >= v)
358
+ return `${Math.round((value / v) * 10) / 10}${s}`;
359
+ }
360
+ return `${Math.round(value)}`;
361
+ }
362
+ function detectOversizedNumbers(html) {
363
+ const hits = [];
364
+ eachVisibleText(html, (text) => {
365
+ for (const m of text.matchAll(OVERSIZED_NUM_RE)) {
366
+ if (oversizedValue(m[2] ?? "", m[3]) >= OVERSIZED_NUM_THRESHOLD) {
367
+ hits.push({
368
+ ruleId: "oversized-number",
369
+ detail: `${m[1] ?? ""}${m[2] ?? ""}${m[3] ?? ""}`,
370
+ });
371
+ }
372
+ }
373
+ return text; // detect-only: never mutate
374
+ });
375
+ return hits;
376
+ }
377
+ function fixOversizedNumbers(html) {
378
+ return eachVisibleText(html, (text) => text.replace(OVERSIZED_NUM_RE, (full, cur, intPart, dec) => {
379
+ const value = oversizedValue(intPart, dec);
380
+ return value >= OVERSIZED_NUM_THRESHOLD
381
+ ? `${cur}${abbreviateMagnitude(value)}`
382
+ : full;
383
+ }));
384
+ }
385
+ // ---- transition-all --------------------------------------------------------
386
+ function declIsTransitionAll(decl) {
387
+ const ci = decl.indexOf(":");
388
+ if (ci < 0)
389
+ return false;
390
+ const prop = decl
391
+ .slice(0, ci)
392
+ .trim()
393
+ .toLowerCase()
394
+ .replace(/^-(?:webkit|moz|o)-/, "");
395
+ if (prop !== "transition" && prop !== "transition-property")
396
+ return false;
397
+ return /\ball\b/i.test(decl.slice(ci + 1));
398
+ }
399
+ function groupHasTransitionAll(decls) {
400
+ if (declsAllow(decls, "transition-all"))
401
+ return false;
402
+ return decls.split(";").some((d) => declIsTransitionAll(d.trim()));
403
+ }
404
+ // ---- em-dash-copy ----------------------------------------------------------
405
+ // A SINGLE em dash (U+2014) in visible copy: the most recognizable
406
+ // generated-text tell; runs of 2+ are decorative-divider's turf. Also catches
407
+ // the entity forms and a spaced en dash (U+2013) standing in for one.
408
+ // Unspaced en dashes are spared: they are legitimate ranges (1-5, Mon-Fri).
409
+ // The dash characters are built from char codes so this source file stays
410
+ // dash-free, the same discipline the rule enforces.
411
+ const EM_DASH = String.fromCharCode(0x2014);
412
+ const EN_DASH = String.fromCharCode(0x2013);
413
+ const EMDASH_ENTITY_SRC = "&mdash;|&#8212;|&#x2014;";
414
+ const EMDASH_SINGLE_SRC = `(?<!${EM_DASH})${EM_DASH}(?!${EM_DASH})`;
415
+ function countEmDashes(text) {
416
+ return ([...text.matchAll(new RegExp(EMDASH_ENTITY_SRC, "gi"))].length +
417
+ [...text.matchAll(new RegExp(EMDASH_SINGLE_SRC, "g"))].length +
418
+ [...text.matchAll(new RegExp(`(?<=\\s)${EN_DASH}(?=\\s)`, "g"))].length);
419
+ }
420
+ function fixEmDashes(text) {
421
+ return text
422
+ .replace(new RegExp(`\\s*(?:${EMDASH_ENTITY_SRC})\\s*`, "gi"), ", ")
423
+ .replace(new RegExp(`\\s*${EMDASH_SINGLE_SRC}\\s*`, "g"), ", ")
424
+ .replace(new RegExp(`\\s+${EN_DASH}\\s+`, "g"), ", ")
425
+ .replace(/^(\s*),\s*/, "$1"); // a dash that OPENED the text node leaves no comma
426
+ }
427
+ // ---- lorem-ipsum -----------------------------------------------------------
428
+ const LOREM_SRC = "\\blorem\\s+ipsum\\b|\\bdolor\\s+sit\\s+amet\\b";
429
+ // ---- missing-alt -----------------------------------------------------------
430
+ function isAltlessImg(tag) {
431
+ if (RESOLVER_SLOT_RE.test(tag))
432
+ return false; // resolver owns the slot's alt
433
+ if (tagAllows(tag, "missing-alt"))
434
+ return false;
435
+ if (isBrokenImg(tag))
436
+ return false; // broken-image already owns this tag
437
+ return !/\balt\s*=/i.test(tag);
438
+ }
439
+ // ---- placeholder-image -----------------------------------------------------
440
+ const PLACEHOLDER_HOST_RE = /\bsrc\s*=\s*["']?(?:https?:)?\/\/(?:www\.)?(?:i\.pravatar\.cc|randomuser\.me|ui-avatars\.com|api\.dicebear\.com|placekitten\.com|placehold\.co|via\.placeholder\.com|placeimg\.com|dummyimage\.com|fakeimg\.pl|lorempixel\.com|loremflickr\.com|picsum\.photos|source\.unsplash\.com)\b/i;
441
+ function isPlaceholderServiceImg(tag) {
442
+ if (tagAllows(tag, "placeholder-image"))
443
+ return false;
444
+ return PLACEHOLDER_HOST_RE.test(tag);
445
+ }
446
+ // ---------------------------------------------------------------------------
447
+ // DOM utilities (structural rules)
448
+ //
449
+ // Some tells are relationships between elements (a badge floated over a hero
450
+ // headline, ticks sprayed on a gauge), not a single declaration. Those rules
451
+ // PARSE the HTML with node-html-parser; nothing is ever executed. Every
452
+ // detect/fix stays wrapped in the engine's try/catch, and every DOM fixer
453
+ // early-returns the input string untouched when it finds no targets, so a
454
+ // clean file is never reserialized.
455
+ // ---------------------------------------------------------------------------
456
+ function tagOf(el) {
457
+ return (el.tagName ?? "").toLowerCase();
458
+ }
459
+ function elClass(el) {
460
+ return (el.getAttribute("class") ?? "").trim();
461
+ }
462
+ /** Element opt-out: a `data-slop-allow="..."` attribute on the element. */
463
+ function elAllows(el, ruleId) {
464
+ return allowListHas(el.getAttribute("data-slop-allow"), ruleId);
465
+ }
466
+ /** data-slop-allow opt-out checked on `el` and every ancestor up to `stop`. */
467
+ function allowsUpTo(el, ruleId, stop) {
468
+ for (let p = el; p; p = p.parentNode) {
469
+ if (elAllows(p, ruleId))
470
+ return true;
471
+ if (p === stop)
472
+ break;
473
+ }
474
+ return false;
475
+ }
476
+ /** Naive cascade: class name -> concatenated declaration bodies from <style>. */
477
+ function collectClassDecls(html) {
478
+ const map = new Map();
479
+ for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
480
+ const css = stripCssComments(m[1]);
481
+ for (const rm of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
482
+ for (const cm of rm[1].matchAll(/\.([A-Za-z0-9_-]+)/g)) {
483
+ map.set(cm[1], `${map.get(cm[1]) ?? ""};${rm[2]}`);
484
+ }
485
+ }
486
+ }
487
+ return map;
488
+ }
489
+ /** An element's effective declarations: inline style + its classes' bodies. */
490
+ function elDecls(el, map) {
491
+ let d = el.getAttribute("style") ?? "";
492
+ for (const c of elClass(el).split(/\s+/).filter(Boolean))
493
+ d += `;${map.get(c) ?? ""}`;
494
+ return d;
495
+ }
496
+ function isDescendantOf(child, ancestor) {
497
+ for (let p = child.parentNode; p; p = p.parentNode)
498
+ if (p === ancestor)
499
+ return true;
500
+ return false;
501
+ }
502
+ /**
503
+ * Split a CSS value on `sep` at the TOP level only (never inside parens, so
504
+ * cubic-bezier(0.4, 0, 0.2, 1) stays a single token).
505
+ */
506
+ function splitTopLevel(value, sep) {
507
+ const out = [];
508
+ let depth = 0;
509
+ let cur = "";
510
+ for (const ch of value) {
511
+ if (ch === "(")
512
+ depth++;
513
+ else if (ch === ")")
514
+ depth = Math.max(0, depth - 1);
515
+ if (ch === sep && depth === 0) {
516
+ out.push(cur);
517
+ cur = "";
518
+ }
519
+ else {
520
+ cur += ch;
521
+ }
522
+ }
523
+ out.push(cur);
524
+ return out;
525
+ }
526
+ /**
527
+ * Replace every `startRe(`...`)` span (balanced parens) in `src` via
528
+ * `transform`. Bails on unbalanced input rather than corrupting output.
529
+ */
530
+ function replaceBalanced(src, startRe, transform) {
531
+ let out = "";
532
+ let count = 0;
533
+ let i = 0;
534
+ startRe.lastIndex = 0;
535
+ let m;
536
+ while ((m = startRe.exec(src)) !== null) {
537
+ const start = m.index;
538
+ const openParen = start + m[0].length - 1; // index of "("
539
+ let depth = 0;
540
+ let j = openParen;
541
+ for (; j < src.length; j++) {
542
+ const c = src[j];
543
+ if (c === "(")
544
+ depth++;
545
+ else if (c === ")") {
546
+ depth--;
547
+ if (depth === 0)
548
+ break;
549
+ }
550
+ }
551
+ if (depth !== 0)
552
+ break; // unbalanced: bail rather than corrupt output
553
+ const end = j + 1;
554
+ out += src.slice(i, start) + transform(src.slice(start, end), src.slice(openParen + 1, j));
555
+ count++;
556
+ i = end;
557
+ startRe.lastIndex = end;
558
+ }
559
+ out += src.slice(i);
560
+ return { out, count };
561
+ }
562
+ // ---- entity-fill gradient helpers (gradient-fill / multicolor-fill) -------
563
+ //
564
+ // A rounded ENTITY (icon tile, card, chip, avatar, filled button) whose
565
+ // background is a GRADIENT is a top generated-UI tell. Two sibling FIX rules
566
+ // split by hue count:
567
+ // - gradient-fill: a SINGLE-hue gradient fill, flattened to a SOLID color
568
+ // (the first saturated stop). Solid saturated tiles are
569
+ // legitimate; only the gradient is slop.
570
+ // - multicolor-fill: a MULTI-hue gradient fill (pink to purple, orange to
571
+ // pink) can't fairly keep one of several competing hues,
572
+ // so it is muted to a neutral surface tone.
573
+ //
574
+ // SCOPE: an entity is discriminated by `border-radius` in the SAME declaration
575
+ // block as the gradient background. That spares full-bleed page/section/hero
576
+ // gradients (no radius) while catching every rounded tile/card/chip/button.
577
+ //
578
+ // CARVE-OUTS so a legitimate fill is never broken:
579
+ // - background-clip:text -> gradient-text owns it.
580
+ // - ANY url() in a background decl -> a photo + scrim, not a tile fill.
581
+ // - NO saturated color stop (pure black/white scrims and tints, or all-var
582
+ // gradients we can't read) -> not a "color" tell; left intact.
583
+ const ENTITY_BG_PROPS = new Set(["background", "background-image", "background-color"]);
584
+ const GRADIENT_FN_START_RE = /\b(?:linear|radial|conic)-gradient\s*\(/gi;
585
+ const HUE_TOL = 28; // degrees; two stops are the same hue family when closer
586
+ const SAT_MIN = 0.15; // below this a stop is achromatic (scrim/tint), ignored
587
+ const MUTED_ENTITY_FILL = "var(--surface, rgba(128,128,128,0.12))";
588
+ /** Split "prop: value" declarations on top-level `;`, lower-casing the prop. */
589
+ function splitDeclList(decls) {
590
+ return stripCssComments(decls)
591
+ .split(";")
592
+ .map((d) => d.trim())
593
+ .filter(Boolean)
594
+ .map((d) => {
595
+ const i = d.indexOf(":");
596
+ return i < 0
597
+ ? { prop: "", val: d }
598
+ : { prop: d.slice(0, i).trim().toLowerCase(), val: d.slice(i + 1).trim() };
599
+ });
600
+ }
601
+ /** The first NON-repeating gradient function (balanced parens) in a value, or
602
+ * null. `repeating-*-gradient` is the repeating-gradient-stripe rule's job. */
603
+ function firstNonRepeatGradient(value) {
604
+ GRADIENT_FN_START_RE.lastIndex = 0;
605
+ let m;
606
+ while ((m = GRADIENT_FN_START_RE.exec(value)) !== null) {
607
+ if (/repeating-$/i.test(value.slice(Math.max(0, m.index - 10), m.index)))
608
+ continue;
609
+ const openParen = m.index + m[0].length - 1;
610
+ let depth = 0;
611
+ for (let j = openParen; j < value.length; j++) {
612
+ const c = value[j];
613
+ if (c === "(")
614
+ depth++;
615
+ else if (c === ")" && --depth === 0)
616
+ return value.slice(m.index, j + 1);
617
+ }
618
+ return null; // unbalanced: bail
619
+ }
620
+ return null;
621
+ }
622
+ /** The first gradient-bearing BACKGROUND value in a decl block, or null. Bails
623
+ * (null) if ANY background decl carries a url(): that's a photo composition,
624
+ * and collapsing it would delete the image. */
625
+ function entityBgGradientValue(decls) {
626
+ let found = null;
627
+ for (const { prop, val } of splitDeclList(decls)) {
628
+ if (!ENTITY_BG_PROPS.has(prop))
629
+ continue;
630
+ if (/\burl\s*\(/i.test(val))
631
+ return null;
632
+ if (!found)
633
+ found = firstNonRepeatGradient(val);
634
+ }
635
+ return found;
636
+ }
637
+ const NAMED_RGB = {
638
+ white: [255, 255, 255], black: [0, 0, 0], red: [255, 0, 0], blue: [0, 0, 255],
639
+ green: [0, 128, 0], orange: [255, 165, 0], purple: [128, 0, 128], pink: [255, 192, 203],
640
+ yellow: [255, 255, 0], cyan: [0, 255, 255], magenta: [255, 0, 255], gray: [128, 128, 128],
641
+ grey: [128, 128, 128], navy: [0, 0, 128], teal: [0, 128, 128], gold: [255, 215, 0],
642
+ crimson: [220, 20, 60], coral: [255, 127, 80], indigo: [75, 0, 130], violet: [238, 130, 238],
643
+ slate: [112, 128, 144], amber: [255, 191, 0], emerald: [16, 185, 129], rose: [244, 63, 94],
644
+ sky: [14, 165, 233], lime: [132, 204, 22],
645
+ };
646
+ function parseHex(h) {
647
+ let s = h.replace("#", "");
648
+ if (s.length === 3 || s.length === 4)
649
+ s = s.split("").map((c) => c + c).join("");
650
+ if (s.length !== 6 && s.length !== 8)
651
+ return null;
652
+ const [r, g, b] = [s.slice(0, 2), s.slice(2, 4), s.slice(4, 6)].map((p) => parseInt(p, 16));
653
+ return [r, g, b].every(Number.isFinite) ? [r, g, b] : null;
654
+ }
655
+ /** Hue (0-360) + saturation (0-1) of a CSS color, or null when unreadable
656
+ * (var()/currentColor/transparent: we can't measure those). */
657
+ function colorHueSat(raw) {
658
+ const c = raw.trim().toLowerCase();
659
+ if (!c || c.startsWith("var(") || c === "transparent" || c === "currentcolor" || c === "inherit")
660
+ return null;
661
+ const hslM = c.match(/hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%/i);
662
+ if (hslM)
663
+ return { h: ((parseFloat(hslM[1]) % 360) + 360) % 360, s: parseFloat(hslM[2]) / 100 };
664
+ let rgb = null;
665
+ if (c.startsWith("#"))
666
+ rgb = parseHex(c);
667
+ else {
668
+ const fn = c.match(/rgba?\(([^)]*)\)/i);
669
+ if (fn) {
670
+ const n = fn[1].split(/[\s,/]+/).map(Number).filter(Number.isFinite);
671
+ if (n.length >= 3)
672
+ rgb = [n[0], n[1], n[2]];
673
+ }
674
+ else {
675
+ const named = c.match(/^[a-z]+/)?.[0];
676
+ if (named && NAMED_RGB[named])
677
+ rgb = NAMED_RGB[named];
678
+ }
679
+ }
680
+ if (!rgb)
681
+ return null;
682
+ const [r, g, b] = rgb.map((v) => v / 255);
683
+ const max = Math.max(r, g, b);
684
+ const min = Math.min(r, g, b);
685
+ const d = max - min;
686
+ const l = (max + min) / 2;
687
+ const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
688
+ let h = 0;
689
+ if (d !== 0) {
690
+ if (max === r)
691
+ h = (((g - b) / d) % 6 + 6) % 6;
692
+ else if (max === g)
693
+ h = (b - r) / d + 2;
694
+ else
695
+ h = (r - g) / d + 4;
696
+ h = (h * 60) % 360;
697
+ }
698
+ return { h, s };
699
+ }
700
+ /** Smallest angular distance between two hues, accounting for wraparound. */
701
+ function hueDelta(a, b) {
702
+ const d = Math.abs(a - b) % 360;
703
+ return d > 180 ? 360 - d : d;
704
+ }
705
+ /** A gradient's color-stop tokens (direction / position parts dropped). */
706
+ function gradientStopColors(gradientFn) {
707
+ const open = gradientFn.indexOf("(");
708
+ const inner = gradientFn.slice(open + 1, gradientFn.lastIndexOf(")"));
709
+ const out = [];
710
+ for (const part of splitTopLevelCommas(inner)) {
711
+ const c = firstColor(part) ?? /(?:^|\s)(var\([^)]*\))/i.exec(part)?.[1] ?? null;
712
+ if (c)
713
+ out.push(c);
714
+ }
715
+ return out;
716
+ }
717
+ /** Classify an entity gradient FILL by readable, saturated hues:
718
+ * 'single' (<=1 hue: flatten to solid), 'multi' (>=2 divergent hues: mute),
719
+ * or null (no saturated color: a scrim/tint/var gradient, not this slop). */
720
+ function gradientFillHueClass(gradientFn) {
721
+ const hues = [];
722
+ for (const c of gradientStopColors(gradientFn)) {
723
+ const hs = colorHueSat(c);
724
+ if (hs && hs.s >= SAT_MIN)
725
+ hues.push(hs.h);
726
+ }
727
+ if (hues.length === 0)
728
+ return null;
729
+ for (let i = 0; i < hues.length; i++)
730
+ for (let j = i + 1; j < hues.length; j++)
731
+ if (hueDelta(hues[i], hues[j]) > HUE_TOL)
732
+ return "multi";
733
+ return "single";
734
+ }
735
+ /** Classify a declaration block's entity gradient fill, or null when it isn't
736
+ * one (no rounded entity, opted out, gradient-text, photo composition, or a
737
+ * scrim/tint we leave alone). One classifier drives BOTH sibling rules. */
738
+ function entityFillClass(decls) {
739
+ if (declsAllow(decls, "gradient-fill") || declsAllow(decls, "multicolor-fill"))
740
+ return null;
741
+ if (!/\bborder-radius\s*:/i.test(decls))
742
+ return null; // not a tile/card/chip/pill
743
+ if (/background-clip\s*:\s*text/i.test(decls))
744
+ return null; // gradient-text owns it
745
+ const grad = entityBgGradientValue(decls);
746
+ return grad ? gradientFillHueClass(grad) : null;
747
+ }
748
+ const isSingleEntityFill = (decls) => entityFillClass(decls) === "single";
749
+ const isMultiEntityFill = (decls) => entityFillClass(decls) === "multi";
750
+ /** First SATURATED stop of a gradient: the color kept when flattening a
751
+ * single-hue fill; falls back to the first readable stop, then the surface. */
752
+ function gradientSolidColor(gradientFn) {
753
+ const colors = gradientStopColors(gradientFn);
754
+ for (const c of colors) {
755
+ const hs = colorHueSat(c);
756
+ if (hs && hs.s >= SAT_MIN)
757
+ return c;
758
+ }
759
+ return colors[0] ?? MUTED_ENTITY_FILL;
760
+ }
761
+ /** Collapse every background* declaration in a block to one solid
762
+ * `background: <color>` (single-hue: the saturated stop; multi-hue: muted).
763
+ * Safe because the predicate already excluded url()-bearing blocks. */
764
+ function fixEntityFill(decls, mode) {
765
+ const grad = entityBgGradientValue(decls);
766
+ if (!grad)
767
+ return decls;
768
+ const color = mode === "multi" ? MUTED_ENTITY_FILL : gradientSolidColor(grad);
769
+ const out = [];
770
+ let placed = false;
771
+ for (const { prop, val } of splitDeclList(decls)) {
772
+ if (!prop) {
773
+ out.push(val);
774
+ }
775
+ else if (ENTITY_BG_PROPS.has(prop)) {
776
+ if (!placed) {
777
+ out.push(`background: ${color}`);
778
+ placed = true;
779
+ }
780
+ // drop any other background* decl; the one solid fill replaces them all
781
+ }
782
+ else {
783
+ out.push(`${prop}: ${val}`);
784
+ }
785
+ }
786
+ return out.join("; ");
787
+ }
788
+ // ---- fake-dot-viz ----------------------------------------------------------
789
+ //
790
+ // A row of >=3 equal, EMPTY dot/node elements faking a "momentum"/"pulse"
791
+ // chart wedged into a list row: it conveys nothing and steals space from the
792
+ // value column. Anchored on a viz-ish wrapper class so it can't touch
793
+ // carousel/pagination indicators or avatar stacks.
794
+ const DOT_CLUSTER_RE = /<(span|div)\b[^>]*\bclass\s*=\s*"[^"]*\b(?:pulse|momentum|sparkdots?|spark-dots|trend-dots|dot-row|dot-grid|dot-cluster|node-row|nodes)\b[^"]*"[^>]*>(?:\s*<(?:span|div|i)\b[^>]*\bclass\s*=\s*"[^"]*\b(?:dot|node|spark)\b[^"]*"[^>]*>\s*<\/(?:span|div|i)>){3,}\s*<\/\1>/gi;
795
+ // ---- gauge / arc structural helpers ----------------------------------------
796
+ //
797
+ // These detectors are STRUCTURAL: they anchor on an "arc/gauge SVG" (an <svg>
798
+ // whose <path d> uses an elliptic-arc command with fill:none, or a
799
+ // gauge-scaffold marker) and scope every removal to that gauge or its
800
+ // container. All removals are design-preserving: the arc, the value, and real
801
+ // labels always survive.
802
+ const GAUGE_SCAFFOLD_CLASS_RE = /\bgesso-viz-(?:gauge|arc)\b/;
803
+ const ARC_PATH_CMD_RE = /[Aa]\s*-?\d/; // an elliptic-arc command in path `d`
804
+ // Groups whose lines are a legitimate, labelled axis/tick scale: exempt from
805
+ // the stray-tick sweep even on a real gauge.
806
+ const TICK_GROUP_CLASS_RE = /tick|axis|grid(?:line)?|scale|marks|ruler/i;
807
+ const STRAY_TICK_MAX_LEN = 16; // viewBox units; real radiating ticks are ~8px
808
+ const STRAY_TICK_MIN_COUNT = 3; // a decorative cluster, not a lone mark
809
+ // A "value / scale" token (7/10), or a percent value (68%) => scale = 100.
810
+ const VIZ_VALUE_SCALE_RE = /(\d{1,3})\s*\/\s*(\d{1,3})\b/;
811
+ const VIZ_PERCENT_RE = /\d{1,3}\s*%/;
812
+ // A centered overlay (translate(-50%,-50%)) is an intentional center-of-ring
813
+ // glyph, NOT a decorative rim ornament: never stripped.
814
+ const CENTERED_TRANSFORM_RE = /translate\(\s*-?50%\s*,\s*-?50%/i;
815
+ // A text whose x sits within this fraction of the viewBox width counts as the
816
+ // centered region (the hero value), not an arc endpoint.
817
+ const ENDPOINT_EDGE_FRAC = 0.2;
818
+ const ENDPOINT_HERO_FONT_PX = 20; // text larger than this is the value, not a label
819
+ function isGTagName(el) {
820
+ return (el.rawTagName ?? "").toLowerCase() === "g";
821
+ }
822
+ /** An <svg> that renders an arc/gauge: a gauge-scaffold marker, OR a STROKED
823
+ * OPEN arc (fill:none) <path>. The fill:none gate is the gauge tell: it
824
+ * excludes FILLED arcs (pie/donut slices, logos, blobs, speech bubbles) that
825
+ * also use the A/a command but are not gauges. */
826
+ function isArcGaugeSvg(svg) {
827
+ // A catalog icon is never a gauge, even though some glyph paths use arc
828
+ // commands. Excluding it keeps nav/status-bar icons out of the gauge scope.
829
+ if (svg.getAttribute("data-icon"))
830
+ return false;
831
+ const cls = svg.getAttribute("class") ?? "";
832
+ const dataViz = (svg.getAttribute("data-viz") ?? "").toLowerCase();
833
+ if (GAUGE_SCAFFOLD_CLASS_RE.test(cls) || dataViz === "gauge-semi" || dataViz === "progress-arc")
834
+ return true;
835
+ for (const p of svg.querySelectorAll("path")) {
836
+ if (!ARC_PATH_CMD_RE.test(p.getAttribute("d") ?? ""))
837
+ continue;
838
+ if ((p.getAttribute("fill") ?? "").trim().toLowerCase() === "none")
839
+ return true;
840
+ }
841
+ return false;
842
+ }
843
+ function arcGaugeSvgs(root) {
844
+ return root.querySelectorAll("svg").filter(isArcGaugeSvg);
845
+ }
846
+ function svgLineLength(el) {
847
+ const n = (a) => parseFloat(el.getAttribute(a) ?? "");
848
+ const x1 = n("x1"), y1 = n("y1"), x2 = n("x2"), y2 = n("y2");
849
+ if ([x1, y1, x2, y2].some((v) => !Number.isFinite(v)))
850
+ return Infinity;
851
+ return Math.hypot(x2 - x1, y2 - y1);
852
+ }
853
+ /** A thin, short <rect> the size of a tick mark (one dimension hairline-thin,
854
+ * the other within the tick-length budget): the rect form of a radiating tick. */
855
+ function isShortTickRect(el) {
856
+ const w = parseFloat(el.getAttribute("width") ?? "");
857
+ const h = parseFloat(el.getAttribute("height") ?? "");
858
+ if (!Number.isFinite(w) || !Number.isFinite(h))
859
+ return false;
860
+ return Math.min(w, h) <= 4 && Math.max(w, h) <= STRAY_TICK_MAX_LEN;
861
+ }
862
+ /** True if an ancestor up to (incl.) the gauge svg carries a tick/axis/grid
863
+ * class (a legitimate labelled tick group). */
864
+ function underTickGroup(el, stopAt) {
865
+ for (let p = el.parentNode; p; p = p.parentNode) {
866
+ if (TICK_GROUP_CLASS_RE.test(p.getAttribute("class") ?? ""))
867
+ return true;
868
+ if (p === stopAt)
869
+ break;
870
+ }
871
+ return false;
872
+ }
873
+ /** Effective font-size px for an SVG text: the CSS font-size (inline/class via
874
+ * elDecls), falling back to the SVG font-size attribute. null = unknown. */
875
+ function effectiveFontPx(el, map) {
876
+ const m = elDecls(el, map).match(/font-size\s*:\s*(\d+(?:\.\d+)?)px/i);
877
+ if (m)
878
+ return parseFloat(m[1]);
879
+ const attr = parseFloat(el.getAttribute("font-size") ?? "");
880
+ return Number.isFinite(attr) ? attr : null;
881
+ }
882
+ /** Short, ungrouped radiating ticks (<line> or thin <rect>) inside a gauge svg. */
883
+ function findStrayVizTicks(root) {
884
+ const out = [];
885
+ for (const svg of arcGaugeSvgs(root)) {
886
+ if (elAllows(svg, "viz-stray-ticks"))
887
+ continue;
888
+ const lines = svg
889
+ .querySelectorAll("line")
890
+ .filter((ln) => svgLineLength(ln) <= STRAY_TICK_MAX_LEN && !underTickGroup(ln, svg));
891
+ const rects = svg
892
+ .querySelectorAll("rect")
893
+ .filter((r) => isShortTickRect(r) && !underTickGroup(r, svg));
894
+ const ticks = [...lines, ...rects];
895
+ if (ticks.length >= STRAY_TICK_MIN_COUNT)
896
+ out.push(...ticks);
897
+ }
898
+ return out;
899
+ }
900
+ function fixStrayVizTicks(html) {
901
+ const root = parse(html);
902
+ const ticks = findStrayVizTicks(root);
903
+ if (ticks.length === 0)
904
+ return html;
905
+ const groups = new Set();
906
+ for (const t of ticks) {
907
+ const p = t.parentNode;
908
+ if (p && isGTagName(p))
909
+ groups.add(p);
910
+ t.remove();
911
+ }
912
+ // Drop wrapper <g>s the ticks left behind ONLY when truly empty (no element
913
+ // children AND no text): a <g> still holding a <text>/<defs> label survives.
914
+ for (const g of groups) {
915
+ if (g.querySelectorAll("*").length === 0 && (g.text ?? "").trim() === "")
916
+ g.remove();
917
+ }
918
+ return root.toString();
919
+ }
920
+ /** Endpoint <text> on a gauge ("0" at one rim, "N" at the other) that merely
921
+ * restate a /N (or percent) scale already shown on the value. Three guards
922
+ * keep real data safe: the scale must exist, the two labels must SPAN the arc
923
+ * horizontally (one near the left rim, one near the right; a vertical y-axis
924
+ * with both labels on one side is NOT a gauge scale), and neither may be the
925
+ * large centered hero value (resolved font + center-region exclusion). */
926
+ function findRedundantScaleTexts(root, map) {
927
+ const out = [];
928
+ for (const svg of arcGaugeSvgs(root)) {
929
+ if (elAllows(svg, "viz-redundant-scale"))
930
+ continue;
931
+ const container = svg.parentNode ?? svg;
932
+ const text = container.text ?? "";
933
+ const slash = text.match(VIZ_VALUE_SCALE_RE);
934
+ const n = slash ? slash[2] : VIZ_PERCENT_RE.test(text) ? "100" : null;
935
+ if (!n)
936
+ continue;
937
+ const vb = (svg.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/);
938
+ const width = parseFloat(vb[2] ?? "");
939
+ if (!Number.isFinite(width) || width <= 0)
940
+ continue; // can't position-gate => skip (safe)
941
+ const candidates = [];
942
+ for (const t of svg.querySelectorAll("text")) {
943
+ const v = (t.text ?? "").trim();
944
+ if (v !== "0" && v !== n)
945
+ continue;
946
+ const x = parseFloat(t.getAttribute("x") ?? "");
947
+ if (!Number.isFinite(x))
948
+ continue;
949
+ const frac = x / width;
950
+ if (frac > ENDPOINT_EDGE_FRAC && frac < 1 - ENDPOINT_EDGE_FRAC)
951
+ continue; // centered region
952
+ const fs = effectiveFontPx(t, map);
953
+ if (fs !== null && fs > ENDPOINT_HERO_FONT_PX)
954
+ continue; // the hero value, not a label
955
+ candidates.push({ el: t, frac });
956
+ }
957
+ // Require a horizontally-spanning pair (one near left rim, one near right):
958
+ // the arc-endpoint signature. A one-sided y-axis never spans, so it's spared.
959
+ const spans = candidates.some((c) => c.frac <= ENDPOINT_EDGE_FRAC) &&
960
+ candidates.some((c) => c.frac >= 1 - ENDPOINT_EDGE_FRAC);
961
+ if (spans)
962
+ out.push(...candidates.map((c) => c.el));
963
+ }
964
+ return out;
965
+ }
966
+ function fixRedundantScaleTexts(html) {
967
+ const root = parse(html);
968
+ const texts = findRedundantScaleTexts(root, collectClassDecls(html));
969
+ if (texts.length === 0)
970
+ return html;
971
+ for (const t of texts)
972
+ t.remove();
973
+ return root.toString();
974
+ }
975
+ // ---- glyph-on-metric helpers -----------------------------------------------
976
+ //
977
+ // A decorative glyph (a raw emoji "figure", a catalog icon, a trend arrow)
978
+ // placed ON a numeric value: an emoji centered behind a progress ring's "74%",
979
+ // an arrow beside a stat's "12%". The number is the content; the glyph
980
+ // collides with or pollutes it. Two branches, one removal pass:
981
+ // A. DATAVIS: a decorative emoji/icon stacked in a viz container that ALSO
982
+ // holds the value in a different branch (the overlap). Remove the wholly
983
+ // decorative wrapper; keep the arc svg and the number.
984
+ // B. PERCENTAGE: every catalog icon and emoji/symbol glyph inside the
985
+ // bounded stat unit around a "NN%" value, scoped so page chrome and
986
+ // neighbouring stats are never reached.
987
+ const PERCENT_VALUE_RE = /^[+\-]?\d[\d.,]*\s*%$/;
988
+ // A datavis value leaf: a percentage, an N/M ratio, a degree, or a bare/abbrev
989
+ // number (74%, 7/10, 72, 8.4, 320, 1.2k). Whole-string match, so prose never
990
+ // qualifies.
991
+ const DATAVIS_VALUE_RE = /^[+\-]?\d[\d.,]*(?:\s*%|\s*\/\s*\d[\d.,]*|\s*°|[kKmMbB])?$/;
992
+ // A wrapper whose class marks it as pure decoration. Widens removal to the
993
+ // whole layer once it is proven to hold no data.
994
+ const DECO_LAYER_CLASS_RE = /\b(?:illu|illustration|figure|decor(?:ation)?|ornament|blob|orb|glow|halo|backdrop|sticker|mascot|artwork|art)\b/i;
995
+ // Codepoint ranges that read as a decorative glyph, or that join an emoji
996
+ // grapheme via ZWJ: arrows, misc-technical, dingbats, supplemental arrows,
997
+ // misc symbols-and-arrows.
998
+ const GLYPH_SYMBOL_RANGES = "\\u2190-\\u21FF\\u2300-\\u27BF\\u2900-\\u297F\\u2B00-\\u2BFF";
999
+ const EMOJI_INNER = EMOJI_BASE.slice(1, -1);
1000
+ const HAS_CORE_EMOJI_RE = new RegExp(`[${EMOJI_INNER}]`, "u");
1001
+ const VISIBLE_GLYPH_RE = new RegExp(`[${EMOJI_INNER}${GLYPH_SYMBOL_RANGES}]`, "u");
1002
+ // A run made up ONLY of glyph codepoints, modifiers (variation selector, ZWJ,
1003
+ // keycap), and whitespace; nothing else (no letters, digits, %, currency).
1004
+ const GLYPH_RUN_RE = new RegExp(`^[${EMOJI_INNER}${GLYPH_SYMBOL_RANGES}\\uFE0F\\u200D\\u20E3\\s]+$`, "u");
1005
+ const STAT_UNIT_MAX_CHARS = 64;
1006
+ // Page chrome / landmarks the percentage climb must never ascend into, and
1007
+ // whose icons are never stripped.
1008
+ const METRIC_CHROME_SEL = '[data-brief-role="status-bar"],[data-chrome="status-bar"],[data-brief-role="tab-bar"],[data-brief-role="nav-bottom"],nav.tab-bar,nav';
1009
+ /** A leaf whose entire text is one or more emoji graphemes (>= 1 core-emoji
1010
+ * codepoint): a standalone decorative emoji like <div class="figure">...</div>. */
1011
+ function isEmojiOnlyElement(el) {
1012
+ if (el.children.length > 0)
1013
+ return false;
1014
+ const t = (el.text ?? "").trim();
1015
+ return !!t && HAS_CORE_EMOJI_RE.test(t) && GLYPH_RUN_RE.test(t);
1016
+ }
1017
+ /** A leaf whose entire text is decorative glyphs (emoji OR arrows/dingbats/
1018
+ * stars). Broader than isEmojiOnlyElement; used only inside a % stat unit. */
1019
+ function isGlyphLeaf(el) {
1020
+ if (el.children.length > 0)
1021
+ return false;
1022
+ const t = (el.text ?? "").trim();
1023
+ return !!t && VISIBLE_GLYPH_RE.test(t) && GLYPH_RUN_RE.test(t);
1024
+ }
1025
+ /** An icon glyph: an <svg> carrying data-icon or the shared `ic` icon class. */
1026
+ function isIconGlyph(el) {
1027
+ if ((el.tagName ?? "").toLowerCase() !== "svg")
1028
+ return false;
1029
+ if (el.getAttribute("data-icon") != null)
1030
+ return true;
1031
+ return /(?:^|\s)ic(?:$|\s)/.test(el.getAttribute("class") ?? "");
1032
+ }
1033
+ /** A catalog icon: isIconGlyph (<svg> with data-icon/.ic) or a non-svg
1034
+ * [data-icon] / .ic element. <img> is intentionally NOT an icon (a flag or
1035
+ * logo beside a stat can be the data). */
1036
+ function isCatalogIcon(el) {
1037
+ if (isIconGlyph(el))
1038
+ return true;
1039
+ if (tagOf(el) === "svg")
1040
+ return false;
1041
+ if (el.getAttribute("data-icon") != null)
1042
+ return true;
1043
+ return /(?:^|\s)ic(?:$|\s)/.test(el.getAttribute("class") ?? "");
1044
+ }
1045
+ /** The nearest ancestor that reads as a data-viz container: it carries
1046
+ * data-viz, its subtree holds a real arc/gauge svg, or it is ring/gauge-classed. */
1047
+ const GAUGE_CONTAINER_CLASS_RE = /\b(?:gauge|donut|radial|dial|meter|speedometer)\b|(?:progress|activity|score|completion|goal|quota|usage|capacity|percent|pct)[-_]?ring\b|\bring[-_](?:chart|gauge|progress)\b/i;
1048
+ function vizContainerFor(glyph, root) {
1049
+ for (let p = glyph.parentNode; p && p !== root; p = p.parentNode) {
1050
+ if (p.getAttribute("data-viz") != null)
1051
+ return p;
1052
+ if (p.querySelectorAll("svg").some(isArcGaugeSvg))
1053
+ return p;
1054
+ if (GAUGE_CONTAINER_CLASS_RE.test(p.getAttribute("class") ?? ""))
1055
+ return p;
1056
+ }
1057
+ return null;
1058
+ }
1059
+ /** True when `el` carries no DATA: no numeric leaf, no <img>, no [data-viz],
1060
+ * no real gauge svg, and no letters (emoji/symbols do not count). */
1061
+ function isWhollyDecorative(el) {
1062
+ if (el.querySelector("img"))
1063
+ return false;
1064
+ if (el.getAttribute("data-viz") != null)
1065
+ return false;
1066
+ if (el.querySelectorAll("svg").some(isArcGaugeSvg))
1067
+ return false;
1068
+ const t = el.text ?? "";
1069
+ if (/\p{L}/u.test(t))
1070
+ return false;
1071
+ if (DATAVIS_VALUE_RE.test(t.trim()))
1072
+ return false;
1073
+ return !el
1074
+ .querySelectorAll("*")
1075
+ .some((n) => n.children.length === 0 && DATAVIS_VALUE_RE.test((n.text ?? "").trim()));
1076
+ }
1077
+ /** Widen a glyph removal to the smallest wholly-decorative wrapper (aria-hidden
1078
+ * or decoration-classed) below the viz container, so orbiting ring blobs go
1079
+ * with the emoji. Falls back to the glyph element itself. */
1080
+ function decoRemovalTarget(glyph, container) {
1081
+ let target = glyph;
1082
+ for (let p = glyph.parentNode; p && p !== container && isDescendantOf(p, container); p = p.parentNode) {
1083
+ const ariaHidden = (p.getAttribute("aria-hidden") ?? "") === "true";
1084
+ const decoClass = DECO_LAYER_CLASS_RE.test(p.getAttribute("class") ?? "");
1085
+ if (!(ariaHidden || decoClass) || !isWhollyDecorative(p))
1086
+ break;
1087
+ target = p;
1088
+ }
1089
+ return target;
1090
+ }
1091
+ const STAT_ICON_SEL = "svg,img,[data-icon],.ic";
1092
+ const STAT_NUMBER_RE = /^\+?\d{1,4}(?:[.,]\d{1,3})?\s*[%kKmM+]?$/;
1093
+ /** Branch A: a decorative emoji/icon stacked on a datavis number. */
1094
+ function findGlyphOnMetricDatavis(root, map) {
1095
+ const out = [];
1096
+ const candidates = [
1097
+ ...root.querySelectorAll("*").filter(isEmojiOnlyElement),
1098
+ ...root.querySelectorAll(STAT_ICON_SEL).filter(isCatalogIcon),
1099
+ ];
1100
+ for (const g of candidates) {
1101
+ if (g.closest(METRIC_CHROME_SEL))
1102
+ continue;
1103
+ const container = vizContainerFor(g, root);
1104
+ if (!container)
1105
+ continue;
1106
+ if (allowsUpTo(g, "glyph-on-metric", container))
1107
+ continue;
1108
+ // The viz must show a number in a DIFFERENT branch than the glyph: that
1109
+ // shared-container, different-branch relationship is the stack/overlap.
1110
+ const value = container.querySelectorAll("*").find((n) => n.children.length === 0 &&
1111
+ n !== g &&
1112
+ !isDescendantOf(n, g) &&
1113
+ !isDescendantOf(g, n) &&
1114
+ DATAVIS_VALUE_RE.test((n.text ?? "").trim()));
1115
+ if (!value)
1116
+ continue;
1117
+ // An emoji is decorative by nature. A catalog icon must be a floated /
1118
+ // hidden ornament and not the intentional centered-in-ring glyph.
1119
+ if (isCatalogIcon(g)) {
1120
+ const decls = elDecls(g, map);
1121
+ const floated = (g.getAttribute("aria-hidden") ?? "") === "true" ||
1122
+ /position\s*:\s*(?:absolute|fixed)/i.test(decls);
1123
+ if (!floated || CENTERED_TRANSFORM_RE.test(decls))
1124
+ continue;
1125
+ }
1126
+ out.push(decoRemovalTarget(g, container));
1127
+ }
1128
+ return out;
1129
+ }
1130
+ /** Outermost-leaf elements whose entire trimmed text matches `re`. */
1131
+ function metricValueEls(scope, re) {
1132
+ return scope
1133
+ .querySelectorAll("*")
1134
+ .filter((n) => re.test((n.text ?? "").trim()) &&
1135
+ !n.querySelectorAll("*").some((d) => re.test((d.text ?? "").trim())));
1136
+ }
1137
+ /** The bounded stat unit around a value: climb while the ancestor stays one
1138
+ * small stat, stopping before a 2nd value, page chrome, body, or a big card. */
1139
+ function statUnitFor(value, root) {
1140
+ let unit = value;
1141
+ for (let p = value.parentNode; p && p !== root; p = p.parentNode) {
1142
+ if (tagOf(p) === "body" || p.closest(METRIC_CHROME_SEL))
1143
+ break;
1144
+ if ((p.text ?? "").replace(/\s+/g, "").length > STAT_UNIT_MAX_CHARS)
1145
+ break;
1146
+ if (metricValueEls(p, STAT_NUMBER_RE).length >= 2)
1147
+ break;
1148
+ unit = p;
1149
+ }
1150
+ return unit;
1151
+ }
1152
+ /** Branch B: every icon/emoji glyph inside a percentage value's stat unit. */
1153
+ function findGlyphOnMetricPercent(root) {
1154
+ const out = [];
1155
+ for (const value of metricValueEls(root, PERCENT_VALUE_RE)) {
1156
+ const unit = statUnitFor(value, root);
1157
+ const glyphs = [
1158
+ ...unit.querySelectorAll(STAT_ICON_SEL).filter(isCatalogIcon),
1159
+ ...unit.querySelectorAll("*").filter(isGlyphLeaf),
1160
+ ];
1161
+ for (const g of glyphs) {
1162
+ if (g === value || isDescendantOf(value, g) || isDescendantOf(g, value))
1163
+ continue;
1164
+ if (g.closest(METRIC_CHROME_SEL))
1165
+ continue;
1166
+ if (allowsUpTo(g, "glyph-on-metric", unit))
1167
+ continue;
1168
+ out.push(g);
1169
+ }
1170
+ }
1171
+ return out;
1172
+ }
1173
+ /** Remove duplicates and any node whose ancestor is also a target. */
1174
+ function dedupeRemovalTargets(targets) {
1175
+ const set = new Set(targets);
1176
+ return [...set].filter((el) => {
1177
+ for (let p = el.parentNode; p; p = p.parentNode)
1178
+ if (set.has(p))
1179
+ return false;
1180
+ return true;
1181
+ });
1182
+ }
1183
+ function glyphOnMetricTargets(root, map) {
1184
+ return dedupeRemovalTargets([
1185
+ ...findGlyphOnMetricDatavis(root, map),
1186
+ ...findGlyphOnMetricPercent(root),
1187
+ ]);
1188
+ }
1189
+ function glyphKindLabel(el) {
1190
+ const icon = el.getAttribute("data-icon");
1191
+ if (icon)
1192
+ return `icon ${icon}`;
1193
+ const t = (el.text ?? "").trim();
1194
+ if (t && GLYPH_RUN_RE.test(t))
1195
+ return `glyph "${t}"`;
1196
+ return `decoration .${(el.getAttribute("class") ?? "?").split(/\s+/)[0]}`;
1197
+ }
1198
+ function detectGlyphOnMetric(html) {
1199
+ return glyphOnMetricTargets(parse(html), collectClassDecls(html)).map((el) => ({
1200
+ ruleId: "glyph-on-metric",
1201
+ detail: `glyph on a metric (${glyphKindLabel(el)})`,
1202
+ }));
1203
+ }
1204
+ function fixGlyphOnMetric(html) {
1205
+ const root = parse(html);
1206
+ const targets = glyphOnMetricTargets(root, collectClassDecls(html));
1207
+ if (targets.length === 0)
1208
+ return html;
1209
+ for (const el of targets)
1210
+ el.remove();
1211
+ return root.toString();
1212
+ }
1213
+ // ---- stat-label-icon helpers ------------------------------------------------
1214
+ //
1215
+ // The number-over-category stat tile whose category label is prefixed with an
1216
+ // icon: "42 / ALL", "14 / MONUMENTS" with a leading glyph. The label WORD
1217
+ // already names the category, so the leading icon is duplicate information
1218
+ // and visual pollution. We strip the redundant leading icon and keep the
1219
+ // number + word. Tightly scoped so it can't touch icons that carry meaning:
1220
+ // - the label's text is a pure category WORD (letters/spaces, no digits/%),
1221
+ // - an icon (svg/img/[data-icon]/.ic) LEADS that label,
1222
+ // - a numeric value leaf sits in the same small tile (<=24 visible chars)
1223
+ // and BEFORE the label in document order, i.e. the big number is the
1224
+ // primary read and the category is its caption.
1225
+ // The document-order guard excludes a menu/nav row with a leading icon and a
1226
+ // TRAILING count badge, where the icon is the real affordance.
1227
+ const STAT_LABEL_TAGS = "span,div,p,dt,dd,h3,h4,h5,h6,figcaption,a,button,li";
1228
+ const STAT_CATEGORY_RE = /^[A-Za-z][A-Za-z &'’/-]{1,19}$/;
1229
+ function findStatLabelIcons(root) {
1230
+ // Pre-order document index, so we can require "number before category".
1231
+ const order = new Map();
1232
+ let i = 0;
1233
+ const index = (n) => {
1234
+ order.set(n, i++);
1235
+ for (const c of n.childNodes)
1236
+ if (c.tagName)
1237
+ index(c);
1238
+ };
1239
+ index(root);
1240
+ const icons = new Set(root.querySelectorAll(STAT_ICON_SEL));
1241
+ const isAncestorOf = (anc, node) => {
1242
+ for (let p = node.parentNode; p; p = p.parentNode)
1243
+ if (p === anc)
1244
+ return true;
1245
+ return false;
1246
+ };
1247
+ const leads = [];
1248
+ const seen = new Set();
1249
+ for (const el of root.querySelectorAll(STAT_LABEL_TAGS)) {
1250
+ if (el.closest(METRIC_CHROME_SEL))
1251
+ continue;
1252
+ if (elAllows(el, "stat-label-icon"))
1253
+ continue;
1254
+ if (!STAT_CATEGORY_RE.test(el.text.replace(/\s+/g, " ").trim()))
1255
+ continue;
1256
+ const lead = el.childNodes.find((c) => c.tagName);
1257
+ if (!lead || seen.has(lead))
1258
+ continue;
1259
+ const leadIsIcon = icons.has(lead) || (!lead.text.trim() && !!lead.querySelector(STAT_ICON_SEL));
1260
+ if (!leadIsIcon)
1261
+ continue;
1262
+ // A numeric value leaf in the same small tile, positioned before the label.
1263
+ const elIdx = order.get(el) ?? Infinity;
1264
+ let found = false;
1265
+ for (let tile = el.parentNode, hops = 0; tile && hops < 3; tile = tile.parentNode, hops++) {
1266
+ if (tile.text.replace(/\s+/g, "").trim().length > 24)
1267
+ continue;
1268
+ found = tile.querySelectorAll("*").some((n) => n.children.length === 0 &&
1269
+ !isAncestorOf(el, n) &&
1270
+ n !== el &&
1271
+ (order.get(n) ?? Infinity) < elIdx &&
1272
+ STAT_NUMBER_RE.test(n.text.trim()));
1273
+ if (found)
1274
+ break;
1275
+ }
1276
+ if (!found)
1277
+ continue;
1278
+ seen.add(lead);
1279
+ leads.push(lead);
1280
+ }
1281
+ return leads;
1282
+ }
1283
+ // ---- live-clock-eyebrow helpers ---------------------------------------------
1284
+ //
1285
+ // The "LIVE 09:41" eyebrow: a live-status dot/badge, optionally trailed by
1286
+ // the current wall-clock time. Both halves are slop on a device screen: the
1287
+ // status bar already shows the time, and a "LIVE/NOW" dot is decorative
1288
+ // chrome. We strip the whole eyebrow. Match ANCHOR is the visible text: the
1289
+ // element's ENTIRE textContent must be just a LIVE/NOW token, optionally
1290
+ // followed by a separator + HH:MM. That whole-text equality is what keeps
1291
+ // this safe: a genuine "LIVE STREAM" heading, a "Departs 09:41" row, or any
1292
+ // clock time NOT fronted by LIVE/NOW never matches. (Dash separators are
1293
+ // written as unicode escapes so this source stays dash-free.)
1294
+ const LIVE_EYEBROW_TEXT = new RegExp("^[\\s\\u2022\\u00B7\\u25CF\\u25CB\\u25C9\\u23FA\\u2B24]*(?:LIVE|NOW)(?:\\s*[\\u00B7\\u2022|/\\u2013\\u2014-]\\s*\\d{1,2}:\\d{2}(?:\\s*[ap]\\.?\\s?m\\.?)?)?[\\s\\u2022\\u00B7]*$", "i");
1295
+ // Tags small enough to be an eyebrow/badge; a big wrapper never matches
1296
+ // because its textContent would carry the surrounding copy too.
1297
+ const EYEBROW_TAGS = "span,div,p,small,em,strong,b,i,time,label,h6,figcaption";
1298
+ function findLiveEyebrows(root) {
1299
+ const matches = root.querySelectorAll(EYEBROW_TAGS).filter((el) => {
1300
+ if (el.closest('[data-brief-role="status-bar"],[data-chrome="status-bar"]'))
1301
+ return false;
1302
+ if (elAllows(el, "live-clock-eyebrow"))
1303
+ return false;
1304
+ const text = el.text.replace(/\s+/g, " ").trim();
1305
+ if (!text)
1306
+ return false;
1307
+ return LIVE_EYEBROW_TEXT.test(text);
1308
+ });
1309
+ // Keep only the OUTERMOST match in any nest (a flagged <div> whose inner
1310
+ // <span>LIVE</span> also matches) so the fix removes the badge once,
1311
+ // cleanly, instead of leaving a stray "09:41" behind.
1312
+ const set = new Set(matches);
1313
+ return matches.filter((el) => {
1314
+ for (let p = el.parentNode; p; p = p.parentNode) {
1315
+ if (set.has(p))
1316
+ return false;
1317
+ }
1318
+ return true;
1319
+ });
1320
+ }
1321
+ // ---- floating-hero-card helpers ---------------------------------------------
1322
+ //
1323
+ // A small absolutely / fixed-positioned "badge" / "spec" card floated over a
1324
+ // hero or banner: corner-pinned, backdrop-blurred, carrying only a tiny
1325
+ // label / value. Decorative chrome that overlaps the artwork and reads as a
1326
+ // generated-landing signature. We match ONLY a card that is (a)
1327
+ // position:absolute|fixed, (b) a card surface (border-radius + a fill or a
1328
+ // backdrop blur), (c) corner-pinned (a vertical AND a horizontal offset, or a
1329
+ // non-zero inset), (d) short label content with NO heading / link / button /
1330
+ // form / nav / list / media, and (e) overlaying a section / header that also
1331
+ // holds a real headline (so the hero's own content column is never removed).
1332
+ // Outermost matches only; the fix removes them.
1333
+ const FLOAT_CARD_TAGS = "div,aside,figure,span,article,section";
1334
+ const FLOAT_HEADLINE_SEL = "h1,h2";
1335
+ const BADGE_DISQUALIFY_SEL = "h1,h2,h3,h4,h5,h6,a[href],button,input,textarea,select,form,nav,ul,ol,img";
1336
+ function declsPositionedFloat(decls) {
1337
+ return /position\s*:\s*(?:absolute|fixed)/i.test(decls);
1338
+ }
1339
+ // Only true POSITIONING offsets count: `border-top` / `padding-left` must not
1340
+ // register, so each property is anchored to a declaration boundary.
1341
+ function declsCornerPinned(decls) {
1342
+ if (/(?:^|[;{]\s*)inset\s*:\s*[^;]*[1-9]/i.test(decls))
1343
+ return true;
1344
+ const vertical = /(?:^|[;{]\s*)(?:top|bottom)\s*:\s*[^;]/i.test(decls);
1345
+ const horizontal = /(?:^|[;{]\s*)(?:left|right)\s*:\s*[^;]/i.test(decls);
1346
+ return vertical && horizontal;
1347
+ }
1348
+ function declsFloatingCardSurface(decls) {
1349
+ const radius = /border-radius\s*:\s*(?:0*[1-9]|var\(|calc\()/i.test(decls);
1350
+ const fill = /background(?:-color)?\s*:\s*(?!none|transparent|inherit|0\b)[^;]/i.test(decls) ||
1351
+ /backdrop-filter\s*:\s*[^;]*blur/i.test(decls) ||
1352
+ /(?:^|[;{]\s*)box-shadow\s*:\s*(?!none)[^;]*\d/i.test(decls);
1353
+ return radius && fill;
1354
+ }
1355
+ function isDecorativeBadge(el) {
1356
+ if (el.querySelector(BADGE_DISQUALIFY_SEL))
1357
+ return false;
1358
+ const text = el.text.replace(/\s+/g, " ").trim();
1359
+ return /[A-Za-z0-9]/.test(text) && text.length <= 90;
1360
+ }
1361
+ function overlaysHeadline(el) {
1362
+ let depth = 0;
1363
+ for (let p = el.parentNode; p && depth < 8; p = p.parentNode, depth++) {
1364
+ const anc = p;
1365
+ const tag = (anc.tagName ?? "").toLowerCase();
1366
+ const cls = elClass(anc).toLowerCase();
1367
+ const heroish = tag === "section" ||
1368
+ tag === "header" ||
1369
+ /hero|banner|masthead|cover|splash|jumbotron/.test(cls);
1370
+ if (!heroish)
1371
+ continue;
1372
+ for (const h of anc.querySelectorAll(FLOAT_HEADLINE_SEL)) {
1373
+ let inEl = false;
1374
+ for (let q = h.parentNode; q; q = q.parentNode)
1375
+ if (q === el) {
1376
+ inEl = true;
1377
+ break;
1378
+ }
1379
+ if (!inEl)
1380
+ return true;
1381
+ }
1382
+ }
1383
+ return false;
1384
+ }
1385
+ function findFloatingHeroCards(root, map) {
1386
+ const matched = root.querySelectorAll(FLOAT_CARD_TAGS).filter((el) => {
1387
+ if (elAllows(el, "floating-hero-card"))
1388
+ return false;
1389
+ const decls = elDecls(el, map);
1390
+ return (declsPositionedFloat(decls) &&
1391
+ declsCornerPinned(decls) &&
1392
+ declsFloatingCardSurface(decls) &&
1393
+ isDecorativeBadge(el) &&
1394
+ overlaysHeadline(el));
1395
+ });
1396
+ const set = new Set(matched);
1397
+ return matched.filter((el) => {
1398
+ for (let p = el.parentNode; p; p = p.parentNode)
1399
+ if (set.has(p))
1400
+ return false;
1401
+ return true;
1402
+ });
1403
+ }
1404
+ // ---- grid-spacer-void helpers ------------------------------------------------
1405
+ //
1406
+ // A full-span hairline (`grid-column: 1 / -1; height: 1px`) used as a row
1407
+ // divider, placed as a child of a grid whose IMPLICIT rows are a fixed height
1408
+ // (`grid-auto-rows: 168px`), lands in its OWN 168px track: the 1px line draws
1409
+ // at the top and ~167px of empty page shows below it. Switching that grid's
1410
+ // grid-auto-rows to `auto` collapses the divider's row to its 1px content
1411
+ // while the real cells size to their content, and the divider lines survive:
1412
+ // the void is the only thing removed.
1413
+ /** A fixed-tall implicit row height is a void risk only when noticeably
1414
+ * taller than a hairline; below this a fixed auto-row is gap-like, not a row. */
1415
+ const MIN_TALL_AUTO_ROW_PX = 24;
1416
+ /** A divider/separator child this short cannot fill a tall auto-row track. */
1417
+ const MAX_SEPARATOR_HEIGHT_PX = 12;
1418
+ /** The single fixed px value of `grid-auto-rows`, or null (auto / fr / minmax /
1419
+ * multi-track: none of which strands a thin child in a tall track). */
1420
+ function fixedAutoRowPx(decls) {
1421
+ const m = decls.match(/grid-auto-rows\s*:\s*([^;}]+)/i);
1422
+ if (!m)
1423
+ return null;
1424
+ const px = m[1].trim().match(/^(\d+(?:\.\d+)?)px$/);
1425
+ return px ? parseFloat(px[1]) : null;
1426
+ }
1427
+ /** A grid child that spans every column and is only a hairline tall: a row
1428
+ * divider that will be inflated to a whole implicit-row track. */
1429
+ function isThinFullSpanSeparator(decls) {
1430
+ if (!/grid-column\s*:\s*1\s*\/\s*-1\b/i.test(decls))
1431
+ return false;
1432
+ const hm = decls.match(/(?:^|[;{\s])height\s*:\s*(\d+(?:\.\d+)?)px/i);
1433
+ return hm != null && parseFloat(hm[1]) <= MAX_SEPARATOR_HEIGHT_PX;
1434
+ }
1435
+ /**
1436
+ * Class names whose rule declares a fixed `grid-auto-rows` on a grid that
1437
+ * actually contains a thin full-span separator descendant. These are the
1438
+ * grids whose auto-rows we neutralize; an unrelated fixed-row gallery (no
1439
+ * separator child) is left untouched.
1440
+ */
1441
+ function gridSpacerVoidClasses(html) {
1442
+ const out = new Set();
1443
+ let root;
1444
+ try {
1445
+ root = parse(html);
1446
+ }
1447
+ catch {
1448
+ return out;
1449
+ }
1450
+ const map = collectClassDecls(html);
1451
+ const all = root.querySelectorAll("*");
1452
+ const seps = all.filter((el) => isThinFullSpanSeparator(elDecls(el, map)));
1453
+ if (seps.length === 0)
1454
+ return out;
1455
+ for (const el of all) {
1456
+ const decls = elDecls(el, map);
1457
+ if (declsAllow(decls, "grid-spacer-void"))
1458
+ continue;
1459
+ const px = fixedAutoRowPx(decls);
1460
+ if (px === null || px < MIN_TALL_AUTO_ROW_PX)
1461
+ continue;
1462
+ if (!seps.some((s) => isDescendantOf(s, el)))
1463
+ continue;
1464
+ // Pin the fix to whichever of this grid's classes actually carries the
1465
+ // fixed grid-auto-rows (so the CSS rewrite targets the right rule).
1466
+ for (const c of elClass(el).split(/\s+/).filter(Boolean)) {
1467
+ const cd = map.get(c);
1468
+ if (cd && fixedAutoRowPx(cd) !== null)
1469
+ out.add(c);
1470
+ }
1471
+ }
1472
+ return out;
1473
+ }
1474
+ function fixGridSpacerVoid(html) {
1475
+ const classes = gridSpacerVoidClasses(html);
1476
+ if (classes.size === 0)
1477
+ return html;
1478
+ return html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (full, css) => {
1479
+ const clean = stripCssComments(css);
1480
+ const fixed = clean.replace(/([^{}]+)\{([^{}]*)\}/g, (rule, sel, body) => {
1481
+ const selClasses = [...sel.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
1482
+ if (!selClasses.some((c) => classes.has(c)) || fixedAutoRowPx(body) === null)
1483
+ return rule;
1484
+ const next = body.replace(/grid-auto-rows\s*:\s*[^;}]+/i, "grid-auto-rows: auto");
1485
+ return next === body ? rule : `${sel}{${next}}`;
1486
+ });
1487
+ return fixed === clean ? full : full.replace(css, () => fixed);
1488
+ });
1489
+ }
1490
+ // ---- inline-padding helpers (wrap-padding-collision / hscroll-snap-gutter) --
1491
+ const ZERO_LEN_RE = /^0(?:px|rem|em|%|vw|vh)?$/i;
1492
+ /**
1493
+ * The resolved inline (left / right) padding of a declaration block, or null
1494
+ * when there is no non-zero inline padding (so a fix abstains rather than
1495
+ * guessing). Handles `padding` (1-4 values), `padding-inline` (1-2),
1496
+ * `padding-left/right`, and logical `padding-inline-start/end`, honoring CSS
1497
+ * source order so a later longhand overrides an earlier shorthand.
1498
+ */
1499
+ function inlinePaddingOf(decls) {
1500
+ let start = null;
1501
+ let end = null;
1502
+ for (const raw of splitTopLevel(decls, ";")) {
1503
+ const idx = raw.indexOf(":");
1504
+ if (idx < 0)
1505
+ continue;
1506
+ const prop = raw.slice(0, idx).trim().toLowerCase();
1507
+ const val = raw
1508
+ .slice(idx + 1)
1509
+ .replace(/!important/i, "")
1510
+ .trim();
1511
+ if (!val)
1512
+ continue;
1513
+ if (prop === "padding") {
1514
+ const p = splitTopLevel(val, " ").map((s) => s.trim()).filter(Boolean);
1515
+ if (p.length === 1)
1516
+ [start, end] = [p[0], p[0]];
1517
+ else if (p.length === 2 || p.length === 3)
1518
+ [start, end] = [p[1], p[1]];
1519
+ else if (p.length === 4)
1520
+ [start, end] = [p[3], p[1]];
1521
+ }
1522
+ else if (prop === "padding-inline") {
1523
+ const p = splitTopLevel(val, " ").map((s) => s.trim()).filter(Boolean);
1524
+ if (p.length === 1)
1525
+ [start, end] = [p[0], p[0]];
1526
+ else if (p.length >= 2)
1527
+ [start, end] = [p[0], p[1]];
1528
+ }
1529
+ else if (prop === "padding-left" || prop === "padding-inline-start") {
1530
+ start = val;
1531
+ }
1532
+ else if (prop === "padding-right" || prop === "padding-inline-end") {
1533
+ end = val;
1534
+ }
1535
+ }
1536
+ if ((start == null || ZERO_LEN_RE.test(start)) && (end == null || ZERO_LEN_RE.test(end))) {
1537
+ return null;
1538
+ }
1539
+ return { start: start ?? "0", end: end ?? "0" };
1540
+ }
1541
+ // ---- wrap-padding-collision helpers -----------------------------------------
1542
+ //
1543
+ // A section that carries the page's inset / `.wrap` container class (centered:
1544
+ // margin-inline:auto, with a non-zero padding-inline) but ALSO zeroes its OWN
1545
+ // horizontal padding, usually a vertical-only `padding: <v> 0` shorthand whose
1546
+ // sides silently collapse to 0. At equal specificity the later rule wins, so
1547
+ // the container's padding-inline is clobbered and the band runs flush to the
1548
+ // screen edge. The fix strips the horizontal zeros from the offending class's
1549
+ // rule (a `padding: V 0` becomes `padding-block: V`), so the container's inset
1550
+ // survives while the vertical rhythm is preserved.
1551
+ /** True when a rule centers its element horizontally (margin-inline:auto, the
1552
+ * margin shorthand with auto, or symmetric margin-left/right:auto). */
1553
+ function centersHorizontally(decls) {
1554
+ return (/margin-inline\s*:\s*auto/i.test(decls) ||
1555
+ (/margin-inline-start\s*:\s*auto/i.test(decls) &&
1556
+ /margin-inline-end\s*:\s*auto/i.test(decls)) ||
1557
+ (/margin-left\s*:\s*auto/i.test(decls) && /margin-right\s*:\s*auto/i.test(decls)) ||
1558
+ /(?:^|[;{\s])margin\s*:\s*[^;}]*\bauto\b/i.test(decls));
1559
+ }
1560
+ /** True when the declarations set a horizontal/inline padding at all (the
1561
+ * `padding` shorthand, `padding-inline*`, or `padding-left/right`), NOT just
1562
+ * `padding-top/bottom/block`. */
1563
+ function declaresInlinePadding(decls) {
1564
+ return /(?:^|[;{\s])padding(?:-inline(?:-start|-end)?|-left|-right)?\s*:/i.test(decls);
1565
+ }
1566
+ /** A rule body that explicitly zeroes its horizontal padding: it declares an
1567
+ * inline padding, yet `inlinePaddingOf` resolves to no non-zero inset. This
1568
+ * is the override that clobbers a container's padding-inline. */
1569
+ function isInlineZeroPaddingBody(decls) {
1570
+ return declaresInlinePadding(decls) && inlinePaddingOf(decls) === null;
1571
+ }
1572
+ /**
1573
+ * Classes that zero their horizontal padding while co-applied (same element)
1574
+ * with an inset container class. The inset container is detected by signature
1575
+ * (centered + a non-zero padding-inline), never by a hardcoded name, so it
1576
+ * holds whatever the author called their wrapper.
1577
+ */
1578
+ function wrapPaddingCollisionClasses(html) {
1579
+ const out = new Set();
1580
+ const map = collectClassDecls(html);
1581
+ const containers = new Set();
1582
+ for (const [cls, decls] of map) {
1583
+ if (centersHorizontally(decls) && inlinePaddingOf(decls) !== null)
1584
+ containers.add(cls);
1585
+ }
1586
+ if (containers.size === 0)
1587
+ return out;
1588
+ let root;
1589
+ try {
1590
+ root = parse(html);
1591
+ }
1592
+ catch {
1593
+ return out;
1594
+ }
1595
+ for (const el of root.querySelectorAll("*")) {
1596
+ const classes = elClass(el).split(/\s+/).filter(Boolean);
1597
+ if (!classes.some((c) => containers.has(c)))
1598
+ continue;
1599
+ for (const c of classes) {
1600
+ if (containers.has(c))
1601
+ continue;
1602
+ const d = map.get(c);
1603
+ if (d && isInlineZeroPaddingBody(d) && !declsAllow(d, "wrap-padding-collision"))
1604
+ out.add(c);
1605
+ }
1606
+ }
1607
+ return out;
1608
+ }
1609
+ /** Drop the horizontal-zero padding from a rule body, preserving vertical
1610
+ * padding (`padding: V 0` becomes `padding-block: V`) so the container's
1611
+ * padding-inline can apply. */
1612
+ function stripHorizontalPadding(body) {
1613
+ const out = [];
1614
+ for (const raw of splitTopLevel(body, ";")) {
1615
+ const d = raw.trim();
1616
+ if (!d)
1617
+ continue;
1618
+ const idx = d.indexOf(":");
1619
+ if (idx < 0) {
1620
+ out.push(d);
1621
+ continue;
1622
+ }
1623
+ const prop = d.slice(0, idx).trim().toLowerCase();
1624
+ const val = d.slice(idx + 1).trim();
1625
+ if (prop === "padding-left" ||
1626
+ prop === "padding-right" ||
1627
+ prop === "padding-inline" ||
1628
+ prop === "padding-inline-start" ||
1629
+ prop === "padding-inline-end") {
1630
+ continue; // drop horizontal / inline-logical padding; the container supplies it
1631
+ }
1632
+ if (prop === "padding") {
1633
+ const p = splitTopLevel(val, " ")
1634
+ .map((s) => s.trim())
1635
+ .filter(Boolean);
1636
+ if (p.length <= 1)
1637
+ continue; // 1-value all-zero shorthand: drop entirely
1638
+ if (p.length === 2) {
1639
+ out.push(`padding-block: ${p[0]}`); // V H: keep vertical only
1640
+ continue;
1641
+ }
1642
+ out.push(`padding-top: ${p[0]}`); // 3/4-value: keep top/bottom
1643
+ out.push(`padding-bottom: ${p[2] ?? p[0]}`);
1644
+ continue;
1645
+ }
1646
+ out.push(d);
1647
+ }
1648
+ return out.join("; ");
1649
+ }
1650
+ function fixWrapPaddingCollision(html) {
1651
+ const classes = wrapPaddingCollisionClasses(html);
1652
+ if (classes.size === 0)
1653
+ return html;
1654
+ return html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (full, css) => {
1655
+ const clean = stripCssComments(css);
1656
+ const fixed = clean.replace(/([^{}]+)\{([^{}]*)\}/g, (rule, sel, body) => {
1657
+ const selClasses = [...sel.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
1658
+ if (!selClasses.some((c) => classes.has(c)) || !isInlineZeroPaddingBody(body))
1659
+ return rule;
1660
+ const next = stripHorizontalPadding(body);
1661
+ return next === body ? rule : `${sel}{${next}}`;
1662
+ });
1663
+ return fixed === clean ? full : full.replace(css, () => fixed);
1664
+ });
1665
+ }
1666
+ // ---- body-display-contents helpers -------------------------------------------
1667
+ //
1668
+ // display:contents on <body> makes it generate no box: its padding, width,
1669
+ // and flex gap are ALL discarded, so the screen renders with no side padding
1670
+ // and zero section rhythm. Scoped STRICTLY to body: display:contents is
1671
+ // legitimate on wrapper divs.
1672
+ const DISPLAY_CONTENTS_DECL = /display\s*:\s*contents\s*;?/gi;
1673
+ // `body{`, `body {`, or `body` in a selector LIST (`html, body {`,
1674
+ // `body, .x {`). Deliberately does NOT match descendant selectors
1675
+ // (`body .child {`), where display:contents targets a child, not body.
1676
+ const BODY_RULE = /((?:^|[\s>{},;])body\s*(?:,[^{]*)?\{)([^}]*)(\})/gi;
1677
+ /** display:contents declarations applied to BODY: inline styles on any
1678
+ * <body> open tag (duplicates included) plus any `body{}` CSS rule. */
1679
+ function bodyDisplayContentsCount(html) {
1680
+ let count = 0;
1681
+ for (const m of html.matchAll(/<body\b[^>]*\bstyle\s*=\s*["']([^"']*)["']/gi)) {
1682
+ if (/display\s*:\s*contents/i.test(m[1]) && !declsAllow(m[1], "body-display-contents"))
1683
+ count++;
1684
+ }
1685
+ for (const m of html.matchAll(BODY_RULE)) {
1686
+ if (/display\s*:\s*contents/i.test(m[2]) && !declsAllow(m[2], "body-display-contents"))
1687
+ count++;
1688
+ }
1689
+ return count;
1690
+ }
1691
+ /** Strip the display:contents declaration from body inline styles and
1692
+ * `body{}` rules only; every other declaration is left intact. */
1693
+ function stripBodyDisplayContents(html) {
1694
+ return html
1695
+ .replace(/(<body\b[^>]*\bstyle\s*=\s*["'])([^"']*)(["'])/gi, (_m, pre, style, post) => /display\s*:\s*contents/i.test(style) && !declsAllow(style, "body-display-contents")
1696
+ ? pre + style.replace(DISPLAY_CONTENTS_DECL, "").trim() + post
1697
+ : pre + style + post)
1698
+ .replace(BODY_RULE, (_m, pre, decls, post) => /display\s*:\s*contents/i.test(decls) && !declsAllow(decls, "body-display-contents")
1699
+ ? pre + decls.replace(DISPLAY_CONTENTS_DECL, "") + post
1700
+ : pre + decls + post);
1701
+ }
1702
+ // ---- hscroll-snap-gutter helpers ---------------------------------------------
1703
+ //
1704
+ // A horizontal scroll-snap carousel whose side gutter is the container's OWN
1705
+ // inline padding loses that gutter when the browser force-snaps the first
1706
+ // card flush to the edge: the snapport is the scrollport minus scroll-padding
1707
+ // (default 0), so scroll-snap-align:start under mandatory snap rests
1708
+ // scrollLeft past the leading padding. The fix mirrors the inline padding
1709
+ // into scroll-padding-inline so the cards snap inside the gutter. It is
1710
+ // additive (base tier): a screen without it is not "slop", so it never feeds
1711
+ // severity, but it is auto-fixed on every fix pass.
1712
+ /** A horizontally-scrolling, inline-axis snap container (the at-risk shape). */
1713
+ function isHScrollSnapContainer(decls) {
1714
+ const horizScroll = /(?:^|[\s;{])overflow(?:-x)?\s*:\s*(?:auto|scroll)/i.test(decls);
1715
+ const inlineSnap = /(?:^|[\s;{])scroll-snap-type\s*:\s*[^;}"']*\b(?:x|inline|both)\b/i.test(decls);
1716
+ return horizScroll && inlineSnap;
1717
+ }
1718
+ /** True once any scroll-padding longhand/shorthand is present (idempotency). */
1719
+ function hasScrollPadding(decls) {
1720
+ return /(?:^|[\s;{])scroll-padding(?:-inline|-block|-top|-bottom|-left|-right|-inline-start|-inline-end|-block-start|-block-end)?\s*:/i.test(decls);
1721
+ }
1722
+ /** A snap carousel that insets its cards with padding but sets no scroll-padding. */
1723
+ function isLeakyHScrollSnapGroup(decls) {
1724
+ if (declsAllow(decls, "hscroll-snap-gutter"))
1725
+ return false;
1726
+ return (isHScrollSnapContainer(decls) && !hasScrollPadding(decls) && inlinePaddingOf(decls) !== null);
1727
+ }
1728
+ /** Count leaky snap carousels across <style> rule groups + inline styles. */
1729
+ function countLeakyHScrollSnap(html) {
1730
+ let n = 0;
1731
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
1732
+ const clean = stripCssComments(block[1]);
1733
+ for (const m of clean.matchAll(/[^{}]+\{([^{}]*)\}/g)) {
1734
+ if (isLeakyHScrollSnapGroup(m[1]))
1735
+ n++;
1736
+ }
1737
+ }
1738
+ for (const inline of html.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi)) {
1739
+ if (isLeakyHScrollSnapGroup(inline[1]))
1740
+ n++;
1741
+ }
1742
+ return n;
1743
+ }
1744
+ /** Append scroll-padding-inline matching the inline gutter. Idempotent + abstaining. */
1745
+ function addScrollPaddingInline(decls) {
1746
+ if (hasScrollPadding(decls))
1747
+ return decls;
1748
+ const pad = inlinePaddingOf(decls);
1749
+ if (!pad)
1750
+ return decls;
1751
+ const value = pad.start === pad.end ? pad.start : `${pad.start} ${pad.end}`;
1752
+ const trimmed = decls.replace(/\s+$/, "");
1753
+ const sep = trimmed === "" || trimmed.endsWith(";") ? "" : ";";
1754
+ return `${trimmed}${sep}scroll-padding-inline:${value}`;
1755
+ }
1756
+ // ---- base-polish helpers (text-wrap-orphans / font-smoothing) -----------------
1757
+ //
1758
+ // Base-tier rules INJECT a marked base-CSS block once (idempotent on the
1759
+ // marker id). Their absence is not a defect, so they never count toward
1760
+ // pass/severity; only `fix` applies them. To opt a document out entirely,
1761
+ // ship your own (even empty) <style> with the same id.
1762
+ /** True when a `<style id="...">` polish block is already present (idempotency). */
1763
+ function hasPolishBlock(html, id) {
1764
+ return new RegExp(`<style[^>]*\\bid=["']${id}["']`, "i").test(html);
1765
+ }
1766
+ /**
1767
+ * True for a full page (has <html>/<body>/</head>), false for a bare fragment.
1768
+ * Base-style injection only targets real pages so it never mutates a snippet.
1769
+ */
1770
+ function isFullDocument(html) {
1771
+ return /<html\b|<body\b|<\/head>/i.test(html);
1772
+ }
1773
+ /**
1774
+ * Inject a polish `<style id>` block once. Idempotent on the id. Prefers
1775
+ * </head>; falls back to just after <body ...>, else prepends.
1776
+ */
1777
+ function injectPolishBlock(html, id, css) {
1778
+ if (hasPolishBlock(html, id))
1779
+ return html;
1780
+ const tag = `<style id="${id}">${css}</style>`;
1781
+ if (/<\/head>/i.test(html))
1782
+ return html.replace(/<\/head>/i, `${tag}\n</head>`);
1783
+ const body = html.match(/<body\b[^>]*>/i);
1784
+ if (body)
1785
+ return html.replace(body[0], `${body[0]}${tag}`);
1786
+ return tag + html;
1787
+ }
1788
+ // ---------------------------------------------------------------------------
1789
+ // Masthead helpers (VOL / ISSUE / № print-chrome artifacts). Ported from the
1790
+ // production registry in 0.4.0.
1791
+ // ---------------------------------------------------------------------------
1792
+ // The element's WHOLE textContent must be a vol/issue/serial reference, so a
1793
+ // sentence that merely contains "issue" never matches. Safeguards: (a) a
1794
+ // VOL/ISSUE/EDITION/SERIAL KEYWORD is REQUIRED (a bare "№ 1" rank badge is
1795
+ // spared); (b) abbreviations (VOL/NO) require the trailing dot, keeping "NO 1"
1796
+ // answers safe; (c) HEADINGS (h1-h6) are NOT eligible, so a real section
1797
+ // heading like "VOL. 04" is never deleted.
1798
+ const MASTHEAD_TAGS = "span,small,em,strong,b,i,time,label,figcaption,p,div";
1799
+ const MASTHEAD_EYEBROW_TEXT = /^[\s•·●○—–|/]*(?:(?:issue|edition|serial)\s*[№#]?\s*\d{1,4}|(?:vol|no)\.\s*[№#]?\s*\d{1,4})(?:\s*[•·—–|/]+\s*(?:№\s*)?\d{1,4})*[\s•·]*$/i;
1800
+ function findEyebrowMatches(root, re) {
1801
+ const matches = root.querySelectorAll(MASTHEAD_TAGS).filter((el) => {
1802
+ const text = el.text.replace(/\s+/g, " ").trim();
1803
+ return !!text && re.test(text);
1804
+ });
1805
+ const set = new Set(matches);
1806
+ return matches.filter((el) => {
1807
+ for (let p = el.parentNode; p; p = p.parentNode)
1808
+ if (set.has(p))
1809
+ return false;
1810
+ return true;
1811
+ });
1812
+ }
1813
+ // The BLOCK cousin: a CLUSTER of fake print-publication metadata (VOLUME /
1814
+ // CATALOGUE / EDITION labels + invented numbers + a serial code). Fires only
1815
+ // on >=1 periodical label family AND >=2 total tells (families + serial), in a
1816
+ // TERSE (<=140 char) container with no real content/heading/media and not
1817
+ // inside <footer>/<nav>, so real footers, changelogs, and spec tables are
1818
+ // spared. A single family REPEATED ("Volume 1 / Volume 2") never fires.
1819
+ const MASTHEAD_LABEL_FAMILIES = [
1820
+ /\bvol(?:ume)?\.?\s*[№#]?\s*\d{1,4}\b/i,
1821
+ /\bissue\.?\s*[№#]?\s*\d{1,4}\b/i,
1822
+ /\bedition\.?\s*[№#]?\s*\d{1,4}\b/i,
1823
+ /\bcatal(?:ogue|og)\.?\s*[№#]?\s*\d{1,4}\b/i,
1824
+ /\bfolio\.?\s*[№#]?\s*\d{1,4}\b/i,
1825
+ /№\s*\d{1,4}\b/,
1826
+ ];
1827
+ // A fabricated serial code: HV-IDX-029 (>=2 dash-joined groups). Dash class =
1828
+ // ASCII hyphen + U+2010..U+2015.
1829
+ const MASTHEAD_SERIAL_RE = /\b[A-Z]{2,}[‐-―-][A-Z0-9]{2,}(?:[‐-―-][A-Z0-9]+)+\b/g;
1830
+ const MASTHEAD_BLOCK_TAGS = "div,header,aside,dl,section,table";
1831
+ const MASTHEAD_BLOCK_DISQUALIFY = "h1,h2,h3,h4,h5,h6,a,button,input,textarea,select,form,nav,img,svg,picture,video";
1832
+ function mastheadInChromeLandmark(el) {
1833
+ for (let p = el.parentNode; p; p = p.parentNode) {
1834
+ const tag = (p.tagName ?? "").toLowerCase();
1835
+ if (tag === "footer" || tag === "nav")
1836
+ return true;
1837
+ }
1838
+ return false;
1839
+ }
1840
+ function isPublicationMastheadBlock(el) {
1841
+ // el.text concatenates child text with NO separator; replace tags with
1842
+ // spaces so sibling labels/values stay word-separated.
1843
+ const text = el.innerHTML
1844
+ .replace(/<[^>]+>/g, " ")
1845
+ .replace(/&[a-z]+;/gi, " ")
1846
+ .replace(/\s+/g, " ")
1847
+ .trim();
1848
+ if (!text || text.length > 140)
1849
+ return false;
1850
+ if (el.querySelector(MASTHEAD_BLOCK_DISQUALIFY))
1851
+ return false;
1852
+ if (mastheadInChromeLandmark(el))
1853
+ return false;
1854
+ const distinctFamilies = MASTHEAD_LABEL_FAMILIES.reduce((n, re) => n + (re.test(text) ? 1 : 0), 0);
1855
+ if (distinctFamilies < 1)
1856
+ return false;
1857
+ const hasSerial = (text.match(MASTHEAD_SERIAL_RE)?.length ?? 0) > 0;
1858
+ return distinctFamilies + (hasSerial ? 1 : 0) >= 2;
1859
+ }
1860
+ function findPublicationMastheadBlocks(root) {
1861
+ const matched = root.querySelectorAll(MASTHEAD_BLOCK_TAGS).filter(isPublicationMastheadBlock);
1862
+ const set = new Set(matched);
1863
+ // Outermost match only (remove the container, not its inner rows).
1864
+ return matched.filter((el) => {
1865
+ for (let p = el.parentNode; p; p = p.parentNode)
1866
+ if (set.has(p))
1867
+ return false;
1868
+ return true;
1869
+ });
1870
+ }
1871
+ // ---------------------------------------------------------------------------
1872
+ // Selector-aware CSS walkers (edge-stripe + redundant-border). Unlike
1873
+ // rewriteCssGroups these walk SELECTORS too, so state carve-outs can apply.
1874
+ // ---------------------------------------------------------------------------
1875
+ const STYLE_RULE_RE = /([^{}]+)\{([^{}]*)\}/g;
1876
+ const INLINE_STYLE_TAG_RE = /(<[a-z][a-z0-9-]*\b[^>]*\sstyle\s*=\s*")([^"]*)("[^>]*>)/gi;
1877
+ // A single selected/current row taking one 3-4px leading accent border is a
1878
+ // legal interactive-state treatment; only decorative/repeated rails are slop.
1879
+ const EDGE_STATE_SELECTOR_RE = /\[aria-(?:selected|current)\b|:checked|\.(?:active|selected|current|is-selected|is-active)\b/i;
1880
+ const EDGE_STATE_TAG_RE = /\baria-(?:selected|current)\s*=\s*"(?:true|page|step|location|date|time)"|\bclass\s*=\s*"[^"]*\b(?:active|selected|current|is-selected|is-active)\b/i;
1881
+ function groupHasEdgeStripe(decls) {
1882
+ const re = /border-(?:left|right)(?:-width)?\s*:\s*([^;]+)/gi;
1883
+ let m;
1884
+ while ((m = re.exec(decls)) !== null) {
1885
+ const val = m[1];
1886
+ const w = val.match(/(\d+(?:\.\d+)?)px/);
1887
+ if (w && parseFloat(w[1]) >= 3 && !/\b(?:none|transparent)\b/i.test(val))
1888
+ return true;
1889
+ }
1890
+ return false;
1891
+ }
1892
+ function stripEdgeStripe(decls) {
1893
+ return decls
1894
+ .split(";")
1895
+ .map((d) => d.trim())
1896
+ .filter(Boolean)
1897
+ .filter((d) => !/^border-(?:left|right)(?:-width|-color|-style)?\s*:/i.test(d))
1898
+ .join("; ");
1899
+ }
1900
+ function countEdgeStripes(html) {
1901
+ let n = 0;
1902
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
1903
+ for (const rule of block[1].matchAll(STYLE_RULE_RE)) {
1904
+ if (declsAllow(rule[2], "edge-stripe"))
1905
+ continue;
1906
+ if (!EDGE_STATE_SELECTOR_RE.test(rule[1]) && groupHasEdgeStripe(rule[2]))
1907
+ n++;
1908
+ }
1909
+ }
1910
+ for (const tag of html.matchAll(INLINE_STYLE_TAG_RE)) {
1911
+ if (tagAllows(tag[0], "edge-stripe") || declsAllow(tag[2], "edge-stripe"))
1912
+ continue;
1913
+ if (!EDGE_STATE_TAG_RE.test(tag[0]) && groupHasEdgeStripe(tag[2]))
1914
+ n++;
1915
+ }
1916
+ return n;
1917
+ }
1918
+ function fixEdgeStripes(html) {
1919
+ let out = html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (full, css) => {
1920
+ const fixed = css.replace(STYLE_RULE_RE, (rule, sel, body) => !EDGE_STATE_SELECTOR_RE.test(sel) &&
1921
+ !declsAllow(body, "edge-stripe") &&
1922
+ groupHasEdgeStripe(body)
1923
+ ? `${sel}{${stripEdgeStripe(body)}}`
1924
+ : rule);
1925
+ return fixed === css ? full : full.replace(css, () => fixed);
1926
+ });
1927
+ out = out.replace(INLINE_STYLE_TAG_RE, (full, pre, val, post) => !EDGE_STATE_TAG_RE.test(full) &&
1928
+ !tagAllows(full, "edge-stripe") &&
1929
+ !declsAllow(val, "edge-stripe") &&
1930
+ groupHasEdgeStripe(val)
1931
+ ? `${pre}${stripEdgeStripe(val)}${post}`
1932
+ : full);
1933
+ return out;
1934
+ }
1935
+ // A border is functional (NOT redundant chrome) on interactive controls +
1936
+ // their states, form fields, table grid cells, code/keycap surfaces, and
1937
+ // callouts. Those selectors/tags are never stripped.
1938
+ const BORDER_CONTROL_SELECTOR_RE = /(?:^|[\s,>+~])(?:button|input|select|textarea|td|th|table|pre|code|kbd|samp)\b|\[type\s*=|\[role\s*=\s*["'](?:alert|status|searchbox|textbox)["']|\.(?:btn|button|input|field|chip|tab|segment|toggle|switch|search|search-field|searchbox|textfield|alert|callout|banner|notice)\b|:hover|:active|:focus(?:-visible)?|:disabled/i;
1939
+ const BORDER_CONTROL_TAG_RE = /[\s"']type\s*=|[\s"']role\s*=\s*["'](?:alert|status|searchbox|textbox)["']|^<(?:button|input|select|textarea|td|th|table|pre|code|kbd|samp)\b/i;
1940
+ function hasRealFill(decls) {
1941
+ const m = decls.match(/(?:^|[;{\s])background(?:-color)?\s*:\s*([^;}]+)/i);
1942
+ if (!m)
1943
+ return false;
1944
+ const v = m[1].trim().toLowerCase();
1945
+ return !/^(?:none|transparent|inherit|initial|unset|currentcolor)$/.test(v);
1946
+ }
1947
+ /** Alpha of a border color value, or null when not explicitly transparent
1948
+ * (named / hex6 / a plain var: treat as opaque, i.e. unknown). */
1949
+ function borderColorAlpha(v) {
1950
+ const fn = v.match(/(?:rgba?|hsla?)\(([^)]*)\)/i);
1951
+ if (fn) {
1952
+ const inner = fn[1].trim();
1953
+ const slash = inner.split("/");
1954
+ if (slash.length === 2)
1955
+ return parseAlpha(slash[1]);
1956
+ const parts = inner.split(",").map((s) => s.trim()).filter(Boolean);
1957
+ return parts.length >= 4 ? parseAlpha(parts[3]) : 1;
1958
+ }
1959
+ const hex8 = v.match(/#[0-9a-f]{8}\b/i);
1960
+ if (hex8)
1961
+ return parseInt(hex8[0].slice(7, 9), 16) / 255;
1962
+ return null;
1963
+ }
1964
+ function hasVisibleBorder(decls) {
1965
+ const re = /(?:^|[;{\s])border(?:-(?:top|right|bottom|left))?\s*:\s*([^;}]+)/gi;
1966
+ let m;
1967
+ while ((m = re.exec(decls)) !== null) {
1968
+ const v = m[1];
1969
+ if (/\b(?:none|hidden)\b/i.test(v) || /\btransparent\b/i.test(v))
1970
+ continue;
1971
+ // A deliberate hairline (low-alpha color or a divider/hairline/stroke
1972
+ // token) is NOT a boxy redundant border. Only flag opaque boxes.
1973
+ if (/var\(\s*--[\w-]*(?:divider|hairline|stroke|line)/i.test(v))
1974
+ continue;
1975
+ const alpha = borderColorAlpha(v);
1976
+ if (alpha !== null && alpha <= 0.1)
1977
+ continue;
1978
+ const w = v.match(/(\d*\.?\d+)\s*px/);
1979
+ if (w) {
1980
+ if (parseFloat(w[1]) >= 1)
1981
+ return true;
1982
+ }
1983
+ else if (/\b(?:solid|dashed|dotted|double)\b/i.test(v)) {
1984
+ return true; // no explicit width: CSS default `medium` (~3px), visible
1985
+ }
1986
+ }
1987
+ return false;
1988
+ }
1989
+ function groupHasRedundantBorder(decls) {
1990
+ return hasRealFill(decls) && hasVisibleBorder(decls);
1991
+ }
1992
+ /** Drop border / border-<side|width|style|color|block|inline>; KEEP border-radius. */
1993
+ function stripBorderDecls(decls) {
1994
+ return stripCssComments(decls)
1995
+ .split(";")
1996
+ .map((d) => d.trim())
1997
+ .filter(Boolean)
1998
+ .filter((d) => !/^border(?:-(?:top|right|bottom|left|width|style|color|block|inline))?\s*:/i.test(d))
1999
+ .join("; ");
2000
+ }
2001
+ const borderSkipSelector = (sel) => EDGE_STATE_SELECTOR_RE.test(sel) || BORDER_CONTROL_SELECTOR_RE.test(sel);
2002
+ const borderSkipTag = (tag) => EDGE_STATE_TAG_RE.test(tag) || BORDER_CONTROL_TAG_RE.test(tag);
2003
+ function countRedundantBorders(html) {
2004
+ let n = 0;
2005
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
2006
+ for (const rule of stripCssComments(block[1]).matchAll(STYLE_RULE_RE)) {
2007
+ if (declsAllow(rule[2], "redundant-border"))
2008
+ continue;
2009
+ if (!borderSkipSelector(rule[1]) && groupHasRedundantBorder(rule[2]))
2010
+ n++;
2011
+ }
2012
+ }
2013
+ for (const tag of html.matchAll(INLINE_STYLE_TAG_RE)) {
2014
+ if (tagAllows(tag[0], "redundant-border") || declsAllow(tag[2], "redundant-border"))
2015
+ continue;
2016
+ if (!borderSkipTag(tag[0]) && groupHasRedundantBorder(tag[2]))
2017
+ n++;
2018
+ }
2019
+ return n;
2020
+ }
2021
+ function fixRedundantBorders(html) {
2022
+ let out = html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (full, css) => {
2023
+ const clean = stripCssComments(css);
2024
+ const fixed = clean.replace(STYLE_RULE_RE, (rule, sel, body) => !borderSkipSelector(sel) &&
2025
+ !declsAllow(body, "redundant-border") &&
2026
+ groupHasRedundantBorder(body)
2027
+ ? `${sel}{${stripBorderDecls(body)}}`
2028
+ : rule);
2029
+ return fixed === clean ? full : full.replace(css, () => fixed);
2030
+ });
2031
+ out = out.replace(INLINE_STYLE_TAG_RE, (full, pre, val, post) => !borderSkipTag(full) &&
2032
+ !tagAllows(full, "redundant-border") &&
2033
+ !declsAllow(val, "redundant-border") &&
2034
+ groupHasRedundantBorder(val)
2035
+ ? `${pre}${stripBorderDecls(val)}${post}`
2036
+ : full);
2037
+ return out;
2038
+ }
2039
+ // ---------------------------------------------------------------------------
2040
+ // Over-designed list-row family. Structural tells (a regex can't safely
2041
+ // re-lay-out a row), so these are FLAG-tier in the public pack: on app feed
2042
+ // screens they are real defects, but on a marketing page a testimonial or
2043
+ // feature-card grid is the genre, and a portable detector cannot see genre.
2044
+ // Treat their hits as must-fix on app UI, judgment calls on landings.
2045
+ // ---------------------------------------------------------------------------
2046
+ /** Element children only (skips text / comment nodes). */
2047
+ function elementChildren(el) {
2048
+ return el.childNodes.filter((c) => c.nodeType === 1);
2049
+ }
2050
+ /** Visible text runs (direct text nodes, in document order); letters/digits only. */
2051
+ function textRuns(el) {
2052
+ const out = [];
2053
+ const walk = (n) => {
2054
+ for (const c of n.childNodes) {
2055
+ if (c.nodeType === 1)
2056
+ walk(c);
2057
+ else if (c.nodeType === 3) {
2058
+ const t = String(c.rawText ?? "")
2059
+ .replace(/&[a-z]+;/gi, " ")
2060
+ .replace(/\s+/g, " ")
2061
+ .trim();
2062
+ if (/[A-Za-z0-9]{2,}/.test(t))
2063
+ out.push(t);
2064
+ }
2065
+ }
2066
+ };
2067
+ walk(el);
2068
+ return out;
2069
+ }
2070
+ // An affordance glyph, not a thumbnail: icon-marked, icon-classed (the common
2071
+ // icon-set class names in the wild), or a small fixed-canvas svg (<=32).
2072
+ const ROW_ICON_CLASS_RE = /\b(?:ic|icon|lucide|feather|tabler|heroicons?|material-icons?|fa|bi)\b/i;
2073
+ function svgCanvasSize(el) {
2074
+ const vb = (el.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/);
2075
+ const fromVb = parseFloat(vb[2] ?? "");
2076
+ if (Number.isFinite(fromVb) && fromVb > 0)
2077
+ return fromVb;
2078
+ const w = parseFloat(el.getAttribute("width") ?? "");
2079
+ return Number.isFinite(w) && w > 0 ? w : null;
2080
+ }
2081
+ /** Thumbnails / photos in a row: <img> or a sizeable non-icon <svg>. */
2082
+ function rowMediaCount(row) {
2083
+ return row.querySelectorAll("img,svg").filter((m) => {
2084
+ const tag = (m.tagName ?? "").toLowerCase();
2085
+ if (tag === "img")
2086
+ return true;
2087
+ if (m.getAttribute("data-icon") != null)
2088
+ return false;
2089
+ if (ROW_ICON_CLASS_RE.test(elClass(m)))
2090
+ return false;
2091
+ const size = svgCanvasSize(m);
2092
+ if (size !== null && size <= 32)
2093
+ return false;
2094
+ return true;
2095
+ }).length;
2096
+ }
2097
+ /**
2098
+ * Groups of >=min sibling elements that read as repeating rows / cards: same
2099
+ * tag + leading class under one parent, each carrying real text. Conservative:
2100
+ * used only to require that a tell REPEATS before any row rule fires.
2101
+ */
2102
+ function repeatingRowSets(root, min = 2) {
2103
+ const sets = [];
2104
+ const walk = (node) => {
2105
+ const kids = elementChildren(node);
2106
+ const byKey = new Map();
2107
+ for (const k of kids) {
2108
+ const lead = elClass(k).split(/\s+/)[0] ?? "";
2109
+ const key = `${k.tagName}.${lead}`;
2110
+ const arr = byKey.get(key) ?? [];
2111
+ arr.push(k);
2112
+ byKey.set(key, arr);
2113
+ }
2114
+ for (const arr of byKey.values()) {
2115
+ const rows = arr.filter((r) => r.text.replace(/\s+/g, " ").trim().length >= 8);
2116
+ if (rows.length >= min)
2117
+ sets.push(rows);
2118
+ }
2119
+ for (const k of kids)
2120
+ walk(k);
2121
+ };
2122
+ walk(root);
2123
+ return sets;
2124
+ }
2125
+ const UPPER_KICKER_RE = /^[A-Z0-9][A-Z0-9 ·•|/&'’.-]{2,42}$/;
2126
+ /** A short ALL-CAPS multi-token kicker ("LOCAL FAVORITE · 96 RAVING"). */
2127
+ function isUpperKicker(text) {
2128
+ if (!UPPER_KICKER_RE.test(text))
2129
+ return false;
2130
+ if (!/[A-Z]/.test(text))
2131
+ return false;
2132
+ if (text !== text.toUpperCase())
2133
+ return false;
2134
+ // multi-token (a separator or interior space); excludes a one-word label.
2135
+ return /[·•|]/.test(text) || /\S\s+\S/.test(text);
2136
+ }
2137
+ function rowHasKickerAboveTitle(row) {
2138
+ const runs = textRuns(row);
2139
+ if (runs.length < 2)
2140
+ return false;
2141
+ const ki = runs.findIndex(isUpperKicker);
2142
+ if (ki === -1)
2143
+ return false;
2144
+ return runs.slice(ki + 1).some((r) => /[a-z]/.test(r) && r.length >= 8);
2145
+ }
2146
+ const QUOTE_START_RE = /^(?:&ldquo;|&quot;|&#822[01];|["“])/;
2147
+ function rowHasMultilineMeta(row) {
2148
+ for (const c of row.querySelectorAll("*")) {
2149
+ if (c.querySelectorAll("*").length > 0)
2150
+ continue; // leaf-ish only
2151
+ if (QUOTE_START_RE.test((c.innerHTML ?? "").trim()))
2152
+ return true;
2153
+ }
2154
+ for (const br of row.querySelectorAll("br")) {
2155
+ const host = br.parentNode;
2156
+ const tag = (host?.tagName ?? "").toLowerCase();
2157
+ if (!/^h[1-6]$/.test(tag))
2158
+ return true; // a <br> outside a heading = a wrapped meta line
2159
+ }
2160
+ return false;
2161
+ }
2162
+ const OVERSTUFFED_SLOT_CAP = 4; // > this = overstuffed; 3 info slots is the row budget
2163
+ function rowInfoSlots(row) {
2164
+ return textRuns(row).length + rowMediaCount(row);
2165
+ }
2166
+ function declsHaveCardSurface(decls) {
2167
+ const radius = /border-radius\s*:\s*(?:0*[1-9]|var\(|calc\()/i.test(decls);
2168
+ const fill = /background(?:-color)?\s*:\s*(?!none|transparent|inherit|0\b)[^;]/i.test(decls) ||
2169
+ /\bbox-shadow\s*:\s*(?!none)/i.test(decls);
2170
+ const border = /\bborder(?:-top|-right|-bottom|-left)?\s*:\s*[^;]*\d/i.test(decls);
2171
+ return radius && (fill || border);
2172
+ }
2173
+ function findRowCards(root, classMap) {
2174
+ const out = [];
2175
+ for (const rows of repeatingRowSets(root, 3)) {
2176
+ const carded = rows.filter((r) => rowMediaCount(r) >= 1 &&
2177
+ textRuns(r).length >= 2 &&
2178
+ declsHaveCardSurface(elDecls(r, classMap)));
2179
+ if (carded.length >= 3)
2180
+ out.push(carded);
2181
+ }
2182
+ return out;
2183
+ }
2184
+ // ---------------------------------------------------------------------------
2185
+ // Headline-consistency helpers (multicolor-heading + mixed-style-headline).
2186
+ // ---------------------------------------------------------------------------
2187
+ const MC_HEADING_SEL = "h1,h2,h3";
2188
+ const MC_INLINE_TAGS = new Set(["span", "em", "strong", "b", "i", "mark", "small", "font", "u"]);
2189
+ const COLOR_NEUTRAL_RE = /^(?:inherit|currentcolor|unset|initial|revert)$/i;
2190
+ // A genuine display HEADLINE is prose: a multi-word phrase with lowercase
2191
+ // letters. This gate keeps the heading-scoped rules off app patterns that use
2192
+ // a heading tag but are NOT prose: a stat VALUE ("8.42km" with a colored
2193
+ // unit), an all-caps brand statement, or a one/two-word screen-header label.
2194
+ // Deliberately UNDER-reaches; over-removing an app stat's label is the worse
2195
+ // failure.
2196
+ function isProseHeadline(h) {
2197
+ const text = h.text.replace(/\s+/g, " ").trim();
2198
+ if (!/[a-z]/.test(text))
2199
+ return false;
2200
+ return text.split(/\s+/).filter((w) => /[A-Za-z]/.test(w)).length >= 3;
2201
+ }
2202
+ function lastColorValue(decls) {
2203
+ let out = null;
2204
+ for (const m of decls.matchAll(/(?:^|[;{])\s*color\s*:\s*([^;{}!]+?)\s*(?:!important)?\s*(?=;|}|$)/gi))
2205
+ out = m[1].trim();
2206
+ return out;
2207
+ }
2208
+ // Cascade order: class decls first, inline style last (inline wins).
2209
+ function resolvedColor(el, map) {
2210
+ let d = "";
2211
+ for (const c of elClass(el).split(/\s+/).filter(Boolean))
2212
+ d += `;${map.get(c) ?? ""}`;
2213
+ d += `;${el.getAttribute("style") ?? ""}`;
2214
+ return lastColorValue(d);
2215
+ }
2216
+ function isColoredInline(el, map) {
2217
+ const tag = (el.tagName ?? "").toLowerCase();
2218
+ const eligible = MC_INLINE_TAGS.has(tag) || (tag === "a" && el.getAttribute("href") == null);
2219
+ if (!eligible)
2220
+ return false;
2221
+ const c = resolvedColor(el, map);
2222
+ return !!c && !COLOR_NEUTRAL_RE.test(c);
2223
+ }
2224
+ function headingHasBaseText(h, colored) {
2225
+ let base = false;
2226
+ const walk = (n) => {
2227
+ for (const c of n.childNodes) {
2228
+ if (c.nodeType === 1) {
2229
+ const child = c;
2230
+ if (colored.has(child))
2231
+ continue;
2232
+ walk(child);
2233
+ }
2234
+ else if (c.nodeType === 3 && /[A-Za-z0-9]/.test(String(c.rawText ?? ""))) {
2235
+ base = true;
2236
+ }
2237
+ }
2238
+ };
2239
+ walk(h);
2240
+ return base;
2241
+ }
2242
+ function findMulticolorHeadings(root, map) {
2243
+ const out = [];
2244
+ for (const h of root.querySelectorAll(MC_HEADING_SEL)) {
2245
+ if (elAllows(h, "multicolor-heading"))
2246
+ continue; // intentional opt-out
2247
+ if (!isProseHeadline(h))
2248
+ continue;
2249
+ const parts = h.querySelectorAll("*").filter((d) => isColoredInline(d, map));
2250
+ if (parts.length === 0)
2251
+ continue;
2252
+ if (!headingHasBaseText(h, new Set(parts)))
2253
+ continue;
2254
+ out.push({ heading: h, parts });
2255
+ }
2256
+ return out;
2257
+ }
2258
+ function fixMulticolorHeadings(html) {
2259
+ const root = parse(html);
2260
+ const map = collectClassDecls(html);
2261
+ const found = findMulticolorHeadings(root, map);
2262
+ if (found.length === 0)
2263
+ return html;
2264
+ for (const { parts } of found)
2265
+ for (const d of parts) {
2266
+ const style = (d.getAttribute("style") ?? "")
2267
+ .replace(/(?:^|;)\s*color\s*:[^;]*/gi, "")
2268
+ .replace(/^\s*;+/, "")
2269
+ .trim();
2270
+ d.setAttribute("style", style ? `${style.replace(/;?\s*$/, "")};color:inherit` : "color:inherit");
2271
+ }
2272
+ return root.toString();
2273
+ }
2274
+ // Mixing upright + italic inside ONE headline. Fires ONLY when there is BOTH
2275
+ // upright base text AND an italic fragment (a wholly-italic headline is a
2276
+ // single consistent style, left alone).
2277
+ const ITALIC_TAGS = new Set(["em", "i", "cite", "var", "address", "dfn"]);
2278
+ function lastFontStyle(decls) {
2279
+ let out = null;
2280
+ for (const m of decls.matchAll(/(?:^|[;{])\s*font-style\s*:\s*([^;{}!]+?)\s*(?:!important)?\s*(?=;|}|$)/gi))
2281
+ out = m[1].trim().toLowerCase();
2282
+ return out;
2283
+ }
2284
+ function resolvedItalic(el, map) {
2285
+ let d = "";
2286
+ for (const c of elClass(el).split(/\s+/).filter(Boolean))
2287
+ d += `;${map.get(c) ?? ""}`;
2288
+ d += `;${el.getAttribute("style") ?? ""}`;
2289
+ const fs = lastFontStyle(d);
2290
+ if (fs)
2291
+ return /^(?:italic|oblique)/.test(fs);
2292
+ return ITALIC_TAGS.has((el.tagName ?? "").toLowerCase());
2293
+ }
2294
+ function findMixedStyleHeadings(root, map) {
2295
+ const out = [];
2296
+ for (const h of root.querySelectorAll(MC_HEADING_SEL)) {
2297
+ if (elAllows(h, "mixed-style-headline"))
2298
+ continue; // intentional opt-out
2299
+ if (!isProseHeadline(h))
2300
+ continue;
2301
+ if (resolvedItalic(h, map))
2302
+ continue; // wholly-italic headline is consistent
2303
+ const parts = h.querySelectorAll("*").filter((d) => resolvedItalic(d, map));
2304
+ if (parts.length === 0)
2305
+ continue;
2306
+ if (!headingHasBaseText(h, new Set(parts)))
2307
+ continue; // require a MIX
2308
+ out.push({ heading: h, parts });
2309
+ }
2310
+ return out;
2311
+ }
2312
+ function fixMixedStyleHeadings(html) {
2313
+ const root = parse(html);
2314
+ const map = collectClassDecls(html);
2315
+ const found = findMixedStyleHeadings(root, map);
2316
+ if (found.length === 0)
2317
+ return html;
2318
+ for (const { parts } of found)
2319
+ for (const d of parts) {
2320
+ const style = (d.getAttribute("style") ?? "")
2321
+ .replace(/(?:^|;)\s*font-style\s*:[^;]*/gi, "")
2322
+ .replace(/^\s*;+/, "")
2323
+ .trim();
2324
+ d.setAttribute("style", style ? `${style.replace(/;?\s*$/, "")};font-style:normal` : "font-style:normal");
2325
+ }
2326
+ return root.toString();
2327
+ }
2328
+ // ---------------------------------------------------------------------------
2329
+ // hero-kicker-eyebrow helpers. Targets ONLY the element immediately preceding
2330
+ // the primary <h1> (or the standalone band before the hero block), requires
2331
+ // short uppercase / tracked text, and skips anything carrying links / lists /
2332
+ // nav / media so a real top nav or toolbar is never removed. Section-level
2333
+ // eyebrows (above an h2 list) are intentionally left alone.
2334
+ // ---------------------------------------------------------------------------
2335
+ const KICKER_DISQUALIFY_SEL = "a,button,input,textarea,select,form,nav,ul,ol,li,img";
2336
+ function prevElementSibling(el) {
2337
+ const parent = el.parentNode;
2338
+ if (!parent)
2339
+ return null;
2340
+ const kids = elementChildren(parent);
2341
+ const i = kids.indexOf(el);
2342
+ return i > 0 ? kids[i - 1] : null;
2343
+ }
2344
+ function subtreeDecls(el, map) {
2345
+ let d = elDecls(el, map);
2346
+ for (const c of el.querySelectorAll("*"))
2347
+ d += `;${elDecls(c, map)}`;
2348
+ return d;
2349
+ }
2350
+ function isUpperTrackedKicker(decls, text) {
2351
+ if (/text-transform\s*:\s*uppercase/i.test(decls))
2352
+ return true;
2353
+ const ls = decls.match(/letter-spacing\s*:\s*([\d.]+)\s*(em|rem|px)/i);
2354
+ if (ls) {
2355
+ const v = parseFloat(ls[1]);
2356
+ const unit = ls[2].toLowerCase();
2357
+ if ((unit === "em" || unit === "rem") && v >= 0.08)
2358
+ return true;
2359
+ if (unit === "px" && v >= 1)
2360
+ return true;
2361
+ }
2362
+ return /[A-Z]/.test(text) && text === text.toUpperCase() && !/[a-z]/.test(text);
2363
+ }
2364
+ function findHeroKicker(root, map) {
2365
+ const h1 = root.querySelector("h1");
2366
+ if (!h1 || !isProseHeadline(h1))
2367
+ return null;
2368
+ const prev = prevElementSibling(h1);
2369
+ if (!prev)
2370
+ return null;
2371
+ if (/^h[1-6]$/.test((prev.tagName ?? "").toLowerCase()))
2372
+ return null;
2373
+ if (prev.querySelector(KICKER_DISQUALIFY_SEL))
2374
+ return null;
2375
+ if (elAllows(prev, "hero-kicker-eyebrow"))
2376
+ return null;
2377
+ const text = prev.text.replace(/\s+/g, " ").trim();
2378
+ // A real kicker carries WORDS; a bare page index ("01 / 24") is not the
2379
+ // restating eyebrow this rule targets.
2380
+ if (!/[A-Za-z]/.test(text) || text.length > 60)
2381
+ return null;
2382
+ if (text.split(/\s+/).filter(Boolean).length > 9)
2383
+ return null;
2384
+ return isUpperTrackedKicker(subtreeDecls(prev, map), text) ? prev : null;
2385
+ }
2386
+ // The hero's top-level block: the nearest ancestor of the H1 that is a direct
2387
+ // child of <body>/<main>. Used to find an eyebrow in its OWN band/section.
2388
+ function topLevelBlock(el) {
2389
+ let cur = el;
2390
+ for (;;) {
2391
+ const parent = cur.parentNode;
2392
+ if (!parent)
2393
+ return cur;
2394
+ const ptag = (parent.tagName ?? "").toLowerCase();
2395
+ if (ptag === "body" || ptag === "main" || ptag === "html" || ptag === "")
2396
+ return cur;
2397
+ cur = parent;
2398
+ }
2399
+ }
2400
+ function findLeadingEyebrowSection(root, map) {
2401
+ const h1 = root.querySelector("h1");
2402
+ if (!h1 || !isProseHeadline(h1))
2403
+ return null;
2404
+ const prev = prevElementSibling(topLevelBlock(h1));
2405
+ if (!prev)
2406
+ return null;
2407
+ if (/^h[1-6]$/.test((prev.tagName ?? "").toLowerCase()))
2408
+ return null;
2409
+ if (prev.querySelector(KICKER_DISQUALIFY_SEL))
2410
+ return null;
2411
+ if (elAllows(prev, "hero-kicker-eyebrow"))
2412
+ return null;
2413
+ const text = prev.text.replace(/\s+/g, " ").trim();
2414
+ if (!/[A-Za-z]/.test(text) || text.length > 60)
2415
+ return null;
2416
+ if (text.split(/\s+/).filter(Boolean).length > 9)
2417
+ return null;
2418
+ return isUpperTrackedKicker(subtreeDecls(prev, map), text) ? prev : null;
2419
+ }
2420
+ // Both eyebrow shapes (inline kicker above the H1 + standalone leading band),
2421
+ // de-duplicated (they coincide when the H1 is itself a top-level block).
2422
+ function findHeroEyebrows(root, map) {
2423
+ const out = [];
2424
+ const inline = findHeroKicker(root, map);
2425
+ if (inline)
2426
+ out.push(inline);
2427
+ const lead = findLeadingEyebrowSection(root, map);
2428
+ if (lead && !out.includes(lead))
2429
+ out.push(lead);
2430
+ return out;
2431
+ }
2432
+ function fixHeroKicker(html) {
2433
+ const root = parse(html);
2434
+ const els = findHeroEyebrows(root, collectClassDecls(html));
2435
+ if (els.length === 0)
2436
+ return html;
2437
+ for (const el of els)
2438
+ el.remove();
2439
+ return root.toString();
2440
+ }
2441
+ // ---------------------------------------------------------------------------
2442
+ // reveal-specificity-trap helpers. A scroll-reveal HIDDEN state gated behind
2443
+ // `.js` (`html.js .reveal { opacity: 0 }`, specificity 0-2-1) whose REVEALED
2444
+ // state is written WITHOUT the gate (`.reveal.in`, 0-2-0) loses the cascade
2445
+ // forever: the observer fires but the section never appears. The fix prefixes
2446
+ // the ungated revealed selector with `html.js ` so it ties the gate and wins
2447
+ // on source order. Idempotent by construction (rewritten selectors no longer
2448
+ // match).
2449
+ // ---------------------------------------------------------------------------
2450
+ function findRevealSpecificityTraps(html) {
2451
+ const hits = [];
2452
+ const fixedHtml = html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_full, open, css, close) => {
2453
+ // 1. Hidden classes: `.js .C` / `html.js .C` rules whose body sets
2454
+ // opacity: 0 (the fail-open hidden state).
2455
+ const hidden = new Set();
2456
+ for (const m of css.matchAll(/(?:html)?\.js\s+\.([A-Za-z_][\w-]*)[^{,]*\{([^}]*)\}/g)) {
2457
+ if (/opacity\s*:\s*0(?:[;\s}]|$)/.test(m[2]))
2458
+ hidden.add(m[1]);
2459
+ }
2460
+ if (hidden.size === 0)
2461
+ return open + css + close;
2462
+ // 2. Revealed-state selectors for those classes written without the
2463
+ // gate: a selector segment starting with `.C` plus >=1 more class.
2464
+ let out = css;
2465
+ for (const cls of hidden) {
2466
+ const segRe = new RegExp(String.raw `(^|[,{}]\s*)(\.${cls}(?:\.[\w-]+)+)(\s*[,{])`, "g");
2467
+ out = out.replace(segRe, (full, pre, seg, post) => {
2468
+ hits.push({
2469
+ ruleId: "reveal-specificity-trap",
2470
+ detail: `\`${seg}\` loses to \`html.js .${cls}\``,
2471
+ });
2472
+ return `${pre}html.js ${seg}${post}`;
2473
+ });
2474
+ }
2475
+ return open + out + close;
2476
+ });
2477
+ return { hits, fixedHtml };
2478
+ }
2479
+ // ---------------------------------------------------------------------------
2480
+ // will-change helpers. Only GPU-compositable props are worth promoting; the
2481
+ // rest waste a layer. WILL_CHANGE_OK = values the detector treats as fine;
2482
+ // WILL_CHANGE_KEEP = the strict set the fixer preserves.
2483
+ // ---------------------------------------------------------------------------
2484
+ /** Map `fn` over every CSS context (<style> bodies + inline style values). */
2485
+ function mapCssText(html, fn) {
2486
+ return html
2487
+ .replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open, body, close) => open + fn(body) + close)
2488
+ .replace(/(\sstyle\s*=\s*")([^"]*)(")/gi, (_m, pre, css, post) => pre + fn(css) + post);
2489
+ }
2490
+ /** Sum `counter(css)` over every CSS context (<style> bodies + inline styles). */
2491
+ function countInCss(html, counter) {
2492
+ let n = 0;
2493
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
2494
+ n += counter(block[1]);
2495
+ for (const inline of html.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi))
2496
+ n += counter(inline[1]);
2497
+ return n;
2498
+ }
2499
+ const WILL_CHANGE_OK = new Set([
2500
+ "transform",
2501
+ "opacity",
2502
+ "filter",
2503
+ "clip-path",
2504
+ "scroll-position",
2505
+ "contents",
2506
+ "auto",
2507
+ "initial",
2508
+ "inherit",
2509
+ "unset",
2510
+ ]);
2511
+ const WILL_CHANGE_KEEP = new Set(["transform", "opacity", "filter", "clip-path"]);
2512
+ const WILL_CHANGE_RE = /will-change\s*:\s*([^;}"]*)(;?)/gi;
2513
+ function willChangeProps(value) {
2514
+ return splitTopLevel(value, ",")
2515
+ .map((s) => s.trim())
2516
+ .filter(Boolean);
2517
+ }
2518
+ function countWillChangeMisuse(html) {
2519
+ return countInCss(html, (css) => {
2520
+ if (declsAllow(css, "will-change-misuse"))
2521
+ return 0;
2522
+ let n = 0;
2523
+ for (const m of css.matchAll(WILL_CHANGE_RE)) {
2524
+ if (willChangeProps(m[1]).some((p) => !WILL_CHANGE_OK.has(p.toLowerCase())))
2525
+ n++;
2526
+ }
2527
+ return n;
2528
+ });
2529
+ }
2530
+ function fixWillChange(html) {
2531
+ return mapCssText(html, (css) => {
2532
+ if (declsAllow(css, "will-change-misuse"))
2533
+ return css;
2534
+ return css.replace(WILL_CHANGE_RE, (full, value, semi) => {
2535
+ const props = willChangeProps(value);
2536
+ if (props.length === 0)
2537
+ return full;
2538
+ if (props.every((p) => WILL_CHANGE_OK.has(p.toLowerCase())))
2539
+ return full;
2540
+ const kept = props.filter((p) => WILL_CHANGE_KEEP.has(p.toLowerCase()));
2541
+ // A pure perf hint, so dropping it entirely never changes appearance.
2542
+ return kept.length === 0 ? "" : `will-change: ${kept.join(", ")}${semi}`;
2543
+ });
2544
+ });
2545
+ }
2546
+ // ---------------------------------------------------------------------------
2547
+ // image-outline helper: pick the hairline color from the page's own ground.
2548
+ // A dark UI (body/html background luminance < 0.5) takes the white hairline;
2549
+ // anything else (or no parseable background) defaults to black.
2550
+ // ---------------------------------------------------------------------------
2551
+ function hexLuminance(hex) {
2552
+ const m = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
2553
+ if (!m)
2554
+ return null;
2555
+ let h = m[1];
2556
+ if (h.length === 3)
2557
+ h = h.split("").map((c) => c + c).join("");
2558
+ const r = parseInt(h.slice(0, 2), 16);
2559
+ const g = parseInt(h.slice(2, 4), 16);
2560
+ const b = parseInt(h.slice(4, 6), 16);
2561
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
2562
+ }
2563
+ function pageImageOutlineColor(html) {
2564
+ for (const m of html.matchAll(/(?:^|[\s>{},;])(?:body|html)\b[^{]*\{([^}]*)\}/gi)) {
2565
+ const bg = m[1].match(/background(?:-color)?\s*:\s*(#[0-9a-f]{3,6})\b/i);
2566
+ if (bg) {
2567
+ const lum = hexLuminance(bg[1]);
2568
+ if (lum !== null && lum < 0.5)
2569
+ return "rgba(255,255,255,0.05)";
2570
+ if (lum !== null)
2571
+ return "rgba(0,0,0,0.05)";
2572
+ }
2573
+ }
2574
+ return "rgba(0,0,0,0.05)";
2575
+ }
2576
+ // ---------------------------------------------------------------------------
2577
+ // 0.4.0 canonical-tell helpers (Gap B of the coverage plan): the fingerprint
2578
+ // colors beyond indigo, glow shadows, type-hygiene extremes, blob cards,
2579
+ // template structures, and motion tells.
2580
+ // ---------------------------------------------------------------------------
2581
+ /** Hue/saturation/lightness of a CSS color, or null when unreadable. */
2582
+ function colorHSL(raw) {
2583
+ const hs = colorHueSat(raw);
2584
+ if (!hs)
2585
+ return null;
2586
+ const c = raw.trim().toLowerCase();
2587
+ let rgb = null;
2588
+ if (c.startsWith("#"))
2589
+ rgb = parseHex(c);
2590
+ else {
2591
+ const fn = c.match(/rgba?\(([^)]*)\)/i);
2592
+ if (fn) {
2593
+ const n = fn[1].split(/[\s,/]+/).map(Number).filter(Number.isFinite);
2594
+ if (n.length >= 3)
2595
+ rgb = [n[0], n[1], n[2]];
2596
+ }
2597
+ else {
2598
+ const hslM = c.match(/hsla?\(\s*[\d.]+(?:deg)?[\s,]+[\d.]+%[\s,]+([\d.]+)%/i);
2599
+ if (hslM)
2600
+ return { ...hs, l: parseFloat(hslM[1]) / 100 };
2601
+ const named = c.match(/^[a-z]+/)?.[0];
2602
+ if (named && NAMED_RGB[named])
2603
+ rgb = NAMED_RGB[named];
2604
+ }
2605
+ }
2606
+ if (!rgb)
2607
+ return null;
2608
+ const [r, g, b] = rgb.map((v) => v / 255);
2609
+ return { ...hs, l: (Math.max(r, g, b) + Math.min(r, g, b)) / 2 };
2610
+ }
2611
+ /** Every literal color token in a declaration VALUE (hex / rgb() / hsl()). */
2612
+ const COLOR_TOKEN_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
2613
+ /** Walk a decls block declaration by declaration, skipping custom-property
2614
+ * definitions, `content:` literals, and url(...) spans, and hand each
2615
+ * remaining value to `fn` for a rewritten value (or null to keep it). */
2616
+ function mapSafeDeclValues(decls, fn) {
2617
+ return stripCssComments(decls)
2618
+ .split(";")
2619
+ .map((d) => {
2620
+ const m = d.match(/^(\s*)([-\w]+)(\s*:\s*)([\s\S]*)$/);
2621
+ if (!m)
2622
+ return d;
2623
+ const [, pre, prop, sep, value] = m;
2624
+ if (prop.startsWith("--") || prop.toLowerCase() === "content")
2625
+ return d;
2626
+ if (/url\s*\(/i.test(value))
2627
+ return d;
2628
+ const next = fn(prop.toLowerCase(), value);
2629
+ return next === null ? d : `${pre}${prop}${sep}${next}`;
2630
+ })
2631
+ .join(";");
2632
+ }
2633
+ // The wider violet band behind the indigo-accent hex list: any saturated
2634
+ // purple/violet reads as the generated accent. The exact indigo hexes are
2635
+ // excluded so indigo-accent (which owns them) never double-counts.
2636
+ const INDIGO_EXACT_RE = /^#(?:6366f1|818cf8|4f46e5|4338ca|3730a3|8b5cf6|7c3aed|6d28d9|a78bfa|5b21b6)$/i;
2637
+ function isVioletWashColor(token) {
2638
+ if (INDIGO_EXACT_RE.test(token.trim()))
2639
+ return false;
2640
+ const hsl = colorHSL(token);
2641
+ if (!hsl)
2642
+ return false;
2643
+ return hsl.h >= 252 && hsl.h <= 296 && hsl.s >= 0.3 && hsl.l >= 0.25 && hsl.l <= 0.88;
2644
+ }
2645
+ // Shadow/filter colors are dark-glow's jurisdiction (a violet glow gets its
2646
+ // LAYER dropped, never recolored: rewriting a shadow color to var() would
2647
+ // hide its alpha from the shadow rules and break idempotency).
2648
+ const VIOLET_SKIP_PROP_RE = /shadow|filter/i;
2649
+ function groupHasVioletWash(decls) {
2650
+ let found = false;
2651
+ mapSafeDeclValues(decls, (prop, value) => {
2652
+ if (VIOLET_SKIP_PROP_RE.test(prop))
2653
+ return null;
2654
+ for (const m of value.matchAll(COLOR_TOKEN_RE)) {
2655
+ if (isVioletWashColor(m[0]))
2656
+ found = true;
2657
+ }
2658
+ return null;
2659
+ });
2660
+ return found;
2661
+ }
2662
+ function fixVioletGroup(decls) {
2663
+ return mapSafeDeclValues(decls, (prop, value) => {
2664
+ if (VIOLET_SKIP_PROP_RE.test(prop))
2665
+ return null;
2666
+ const next = value.replace(COLOR_TOKEN_RE, (tok) => isVioletWashColor(tok) ? "var(--accent, currentColor)" : tok);
2667
+ return next === value ? null : next;
2668
+ });
2669
+ }
2670
+ // Tailwind's emerald/green family: the escape-hatch accent models reach for
2671
+ // once purple is off the table. Advisory only.
2672
+ const EMERALD_HEX_RE = /#(?:10b981|34d399|059669|047857|065f46|22c55e|16a34a|4ade80|15803d)\b/gi;
2673
+ function groupHasSafeGreen(decls) {
2674
+ let found = false;
2675
+ mapSafeDeclValues(decls, (_p, value) => {
2676
+ if (value.match(EMERALD_HEX_RE))
2677
+ found = true;
2678
+ return null;
2679
+ });
2680
+ return found;
2681
+ }
2682
+ // Chromatic glow: a non-inset box-shadow / text-shadow layer (or a
2683
+ // drop-shadow() filter) whose color is saturated and whose blur is wide.
2684
+ // heavy-box-shadow owns neutral over-heavy stacks; this owns NEON.
2685
+ function shadowLayerColor(layer) {
2686
+ const m = layer.match(COLOR_TOKEN_RE);
2687
+ return m ? m[0] : null;
2688
+ }
2689
+ function isChromaticGlowLayer(layer) {
2690
+ if (/\binset\b/i.test(layer))
2691
+ return false;
2692
+ const lengths = shadowLayerLengths(layer);
2693
+ const blur = lengths.length >= 3 ? lengths[2] : 0;
2694
+ if (blur < 12)
2695
+ return false;
2696
+ const color = shadowLayerColor(layer);
2697
+ if (!color)
2698
+ return false;
2699
+ const hsl = colorHSL(color);
2700
+ if (!hsl || hsl.s < 0.4)
2701
+ return false;
2702
+ const alpha = shadowLayerAlpha(layer);
2703
+ return alpha >= 0.15;
2704
+ }
2705
+ const SHADOW_DECL_RE = /((?:^|[;{\s])(?:box-shadow|text-shadow)\s*:\s*)([^;}]+)/gi;
2706
+ const DROP_SHADOW_FN_RE = /drop-shadow\(([^)]*)\)/gi;
2707
+ function countDarkGlow(decls) {
2708
+ let n = 0;
2709
+ for (const m of decls.matchAll(SHADOW_DECL_RE)) {
2710
+ for (const layer of splitTopLevelCommas(m[2])) {
2711
+ if (isChromaticGlowLayer(layer))
2712
+ n++;
2713
+ }
2714
+ }
2715
+ for (const m of decls.matchAll(DROP_SHADOW_FN_RE)) {
2716
+ if (isChromaticGlowLayer(m[1]))
2717
+ n++;
2718
+ }
2719
+ return n;
2720
+ }
2721
+ function stripDarkGlow(decls) {
2722
+ let out = decls.replace(SHADOW_DECL_RE, (full, pre, value) => {
2723
+ const kept = splitTopLevelCommas(value).filter((l) => !isChromaticGlowLayer(l));
2724
+ if (kept.length === 0)
2725
+ return ""; // glow was the whole shadow: drop it
2726
+ return `${pre}${kept.map((l) => l.trim()).join(", ")}`;
2727
+ });
2728
+ out = out.replace(DROP_SHADOW_FN_RE, (full, inner) => isChromaticGlowLayer(inner) ? "" : full);
2729
+ return out;
2730
+ }
2731
+ // The warm-cream "tasteful startup" wash: a cream page ground plus a serif
2732
+ // display voice. Advisory; editorial briefs earn it, defaults don't.
2733
+ function pageBackgroundHSL(html) {
2734
+ for (const m of html.matchAll(/(?:^|[\s>{},;])(?:body|html)\b[^{]*\{([^}]*)\}/gi)) {
2735
+ const bg = m[1].match(/background(?:-color)?\s*:\s*([^;}]+)/i);
2736
+ if (bg) {
2737
+ const tok = bg[1].match(COLOR_TOKEN_RE)?.[0];
2738
+ if (tok)
2739
+ return colorHSL(tok);
2740
+ }
2741
+ }
2742
+ return null;
2743
+ }
2744
+ function pageHasSerifDisplay(html) {
2745
+ for (const m of html.matchAll(/font-family\s*:\s*([^;}"]+)/gi)) {
2746
+ const v = m[1].toLowerCase();
2747
+ if (/\bserif\b/.test(v) && !/sans-serif\s*$/.test(v.trim()) && !/\bsans\b/.test(v.split(",")[0] ?? ""))
2748
+ return true;
2749
+ }
2750
+ return false;
2751
+ }
2752
+ // Overused display faces: the four families every list of AI tells names.
2753
+ const OVERUSED_FONT_RE = /\b(?:Inter|Space\s+Grotesk|Geist|Instrument\s+Serif)\b/gi;
2754
+ function findOverusedFonts(html) {
2755
+ const found = new Set();
2756
+ for (const m of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
2757
+ for (const f of m[1].matchAll(OVERUSED_FONT_RE))
2758
+ found.add(f[0].replace(/\s+/g, " "));
2759
+ }
2760
+ for (const m of html.matchAll(/fonts\.googleapis\.com\/css2?\?[^"']*/gi)) {
2761
+ for (const f of decodeURIComponent(m[0]).replace(/\+/g, " ").matchAll(OVERUSED_FONT_RE))
2762
+ found.add(f[0].replace(/\s+/g, " "));
2763
+ }
2764
+ return [...found];
2765
+ }
2766
+ const GENERIC_FAMILIES = new Set([
2767
+ "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
2768
+ "ui-sans-serif", "ui-serif", "ui-monospace", "ui-rounded", "math", "emoji",
2769
+ "inherit", "initial", "unset",
2770
+ ]);
2771
+ /** Distinct leading (non-generic) family names declared on the page. */
2772
+ function declaredFamilies(html) {
2773
+ const out = new Set();
2774
+ let decls = 0;
2775
+ for (const m of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
2776
+ decls++;
2777
+ const first = splitTopLevelCommas(m[1])[0]?.trim().replace(/^["']|["']$/g, "") ?? "";
2778
+ if (first && !GENERIC_FAMILIES.has(first.toLowerCase()))
2779
+ out.add(first.toLowerCase());
2780
+ }
2781
+ return decls >= 2 ? [...out] : ["", ""]; // <2 decls: report as "diverse" (no hit)
2782
+ }
2783
+ // Type-hygiene extremes. Each predicate walks one decls group.
2784
+ function trackingEm(value) {
2785
+ const m = value.match(/(-?[\d.]+)\s*(em|rem|px)/i);
2786
+ if (!m)
2787
+ return null;
2788
+ const v = parseFloat(m[1]);
2789
+ return m[2].toLowerCase() === "px" ? v / 16 : v;
2790
+ }
2791
+ function groupHasCrushedTracking(decls) {
2792
+ const m = decls.match(/letter-spacing\s*:\s*([^;}]+)/i);
2793
+ if (!m)
2794
+ return false;
2795
+ const em = trackingEm(m[1]);
2796
+ return em !== null && em <= -0.05;
2797
+ }
2798
+ function fixCrushedTracking(decls) {
2799
+ return decls.replace(/(letter-spacing\s*:\s*)([^;}]+)/gi, (full, pre, v) => {
2800
+ const em = trackingEm(v);
2801
+ return em !== null && em <= -0.05 ? `${pre}-0.02em` : full;
2802
+ });
2803
+ }
2804
+ function groupHasWideBodyTracking(decls) {
2805
+ if (/text-transform\s*:\s*uppercase/i.test(decls))
2806
+ return false; // tracked caps are a real pattern
2807
+ const fs = decls.match(/font-size\s*:\s*([\d.]+)px/i);
2808
+ if (fs && parseFloat(fs[1]) <= 13)
2809
+ return false; // micro-labels track wide legitimately
2810
+ const m = decls.match(/letter-spacing\s*:\s*([^;}]+)/i);
2811
+ if (!m)
2812
+ return false;
2813
+ const em = trackingEm(m[1]);
2814
+ return em !== null && em >= 0.08;
2815
+ }
2816
+ function fixWideBodyTracking(decls) {
2817
+ return decls.replace(/(letter-spacing\s*:\s*)([^;}]+)/gi, (full, pre, v) => {
2818
+ const em = trackingEm(v);
2819
+ return em !== null && em >= 0.08 ? `${pre}0.01em` : full;
2820
+ });
2821
+ }
2822
+ function groupHasTightLineHeight(decls) {
2823
+ const fs = decls.match(/font-size\s*:\s*([\d.]+)px/i);
2824
+ if (!fs)
2825
+ return false; // no size in the group: inheritance unknown, stay quiet
2826
+ const size = parseFloat(fs[1]);
2827
+ if (size < 13 || size > 20)
2828
+ return false; // display + micro sizes set their own rules
2829
+ const lh = decls.match(/line-height\s*:\s*([\d.]+)(px)?/i);
2830
+ if (!lh)
2831
+ return false;
2832
+ const v = parseFloat(lh[1]);
2833
+ const ratio = lh[2] ? v / size : v;
2834
+ return ratio > 0 && ratio < 1.25;
2835
+ }
2836
+ function fixTightLineHeight(decls) {
2837
+ if (!groupHasTightLineHeight(decls))
2838
+ return decls;
2839
+ return decls.replace(/(line-height\s*:\s*)([\d.]+)(px)?/i, "$11.4");
2840
+ }
2841
+ function groupHasTinyBodyText(decls) {
2842
+ if (/text-transform\s*:\s*uppercase/i.test(decls))
2843
+ return false; // tracked micro-labels
2844
+ const m = decls.match(/font-size\s*:\s*([\d.]+)(px|rem|em)/i);
2845
+ if (!m)
2846
+ return false;
2847
+ const v = parseFloat(m[1]);
2848
+ const px = m[2].toLowerCase() === "px" ? v : v * 16;
2849
+ return px > 0 && px < 11;
2850
+ }
2851
+ function fixTinyBodyText(decls) {
2852
+ if (!groupHasTinyBodyText(decls))
2853
+ return decls;
2854
+ return decls.replace(/(font-size\s*:\s*)([\d.]+)(px|rem|em)/i, (_f, pre) => `${pre}12px`);
2855
+ }
2856
+ const MONO_FAMILY_RE = /font-family\s*:[^;}]*(?:\bmonospace\b|\bmono\b|courier|consolas|menlo)/i;
2857
+ function countMonospaceBody(html) {
2858
+ let n = 0;
2859
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
2860
+ for (const rule of stripCssComments(block[1]).matchAll(STYLE_RULE_RE)) {
2861
+ const sel = rule[1];
2862
+ const bodyOrP = /(?:^|[\s,}])(?:body|p)\s*(?:[,{]|$)/i.test(`${sel}{`);
2863
+ if (bodyOrP && MONO_FAMILY_RE.test(`font-family:${rule[2].match(/font-family\s*:\s*([^;}]+)/i)?.[1] ?? ""}`))
2864
+ n++;
2865
+ }
2866
+ }
2867
+ return n;
2868
+ }
2869
+ // Blob cards + ghost cards.
2870
+ function cardRadiusPx(decls) {
2871
+ const m = decls.match(/border-radius\s*:\s*([\d.]+)px\b/i);
2872
+ return m ? parseFloat(m[1]) : null;
2873
+ }
2874
+ function groupIsOverRounded(decls) {
2875
+ if (!hasRealFill(decls))
2876
+ return false;
2877
+ const r = cardRadiusPx(decls);
2878
+ return r !== null && r >= 40 && r <= 120;
2879
+ }
2880
+ function fixOverRounded(decls) {
2881
+ if (!groupIsOverRounded(decls))
2882
+ return decls;
2883
+ return decls.replace(/(border-radius\s*:\s*)([\d.]+)px\b/i, "$124px");
2884
+ }
2885
+ function groupIsGhostCard(decls) {
2886
+ const hairline = /border[^;:]*:\s*(?:0?\.5|1)px\b/i.test(decls);
2887
+ if (!hairline)
2888
+ return false;
2889
+ for (const m of decls.matchAll(SHADOW_DECL_RE)) {
2890
+ for (const layer of splitTopLevelCommas(m[2])) {
2891
+ if (/\binset\b/i.test(layer))
2892
+ continue;
2893
+ const lengths = shadowLayerLengths(layer);
2894
+ const blur = lengths.length >= 3 ? lengths[2] : 0;
2895
+ const alpha = shadowLayerAlpha(layer);
2896
+ if (blur >= 24 && alpha > 0 && alpha <= 0.18)
2897
+ return true;
2898
+ }
2899
+ }
2900
+ return false;
2901
+ }
2902
+ function stripGhostShadow(decls) {
2903
+ if (!groupIsGhostCard(decls))
2904
+ return decls;
2905
+ return decls
2906
+ .split(";")
2907
+ .filter((d) => !/^\s*(?:box-shadow)\s*:/i.test(d))
2908
+ .join(";");
2909
+ }
2910
+ // Nested cards: a card-surfaced CONTAINER inside another card surface.
2911
+ // Chips/badges/buttons (small filled+rounded leaves) are not containers.
2912
+ function isCardContainer(el, map) {
2913
+ const tag = (el.tagName ?? "").toLowerCase();
2914
+ if (tag === "button" || tag === "a" || tag === "span")
2915
+ return false;
2916
+ if (!declsHaveCardSurface(elDecls(el, map)))
2917
+ return false;
2918
+ const kids = elementChildren(el);
2919
+ return kids.length >= 2 || el.querySelector("h1,h2,h3,h4,h5,h6,p") != null;
2920
+ }
2921
+ function findNestedCards(root, map) {
2922
+ const out = [];
2923
+ for (const el of root.querySelectorAll("div,section,article,aside,li,figure")) {
2924
+ if (!isCardContainer(el, map))
2925
+ continue;
2926
+ if (elAllows(el, "nested-cards"))
2927
+ continue;
2928
+ for (let p = el.parentNode; p; p = p.parentNode) {
2929
+ if (typeof p.getAttribute === "function" && declsHaveCardSurface(elDecls(p, map))) {
2930
+ out.push(el);
2931
+ break;
2932
+ }
2933
+ }
2934
+ }
2935
+ return out;
2936
+ }
2937
+ // Decorative "01 / 02 / 03" scaffolding: leading-zero index leaves.
2938
+ function findNumberedMarkers(root) {
2939
+ const hits = [];
2940
+ for (const el of root.querySelectorAll("*")) {
2941
+ if (el.querySelectorAll("*").length > 0)
2942
+ continue;
2943
+ const text = el.text.replace(/\s+/g, " ").trim();
2944
+ if (/^0[1-9][.)/]?$/.test(text))
2945
+ hits.push(el);
2946
+ }
2947
+ return hits.length >= 2 ? hits : [];
2948
+ }
2949
+ // The universal feature-card template: icon on top, heading, blurb, x3.
2950
+ function isIconToppedCard(row) {
2951
+ const kids = elementChildren(row);
2952
+ if (kids.length < 2)
2953
+ return false;
2954
+ const first = kids[0];
2955
+ const firstTag = (first.tagName ?? "").toLowerCase();
2956
+ const leadIcon = firstTag === "svg" ||
2957
+ firstTag === "img" ||
2958
+ ((firstTag === "div" || firstTag === "span") &&
2959
+ elementChildren(first).length === 1 &&
2960
+ ["svg", "img"].includes((elementChildren(first)[0].tagName ?? "").toLowerCase()) &&
2961
+ textRuns(first).length === 0);
2962
+ if (!leadIcon)
2963
+ return false;
2964
+ return row.querySelector("h2,h3,h4,h5") != null && row.querySelector("p") != null;
2965
+ }
2966
+ function findIconToppedCardSets(root) {
2967
+ const out = [];
2968
+ for (const rows of repeatingRowSets(root, 3)) {
2969
+ const matched = rows.filter((r) => !elAllows(r, "icon-topped-feature-card") && isIconToppedCard(r));
2970
+ if (matched.length >= 3)
2971
+ out.push(matched);
2972
+ }
2973
+ return out;
2974
+ }
2975
+ // Motion tells.
2976
+ const CUBIC_BEZIER_RE = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/gi;
2977
+ function bezierOvershoots(y1, y2) {
2978
+ const a = parseFloat(y1);
2979
+ const b = parseFloat(y2);
2980
+ return a < 0 || a > 1 || b < 0 || b > 1;
2981
+ }
2982
+ function countBounceEasing(decls) {
2983
+ let n = 0;
2984
+ for (const m of decls.matchAll(CUBIC_BEZIER_RE)) {
2985
+ if (bezierOvershoots(m[2], m[4]))
2986
+ n++;
2987
+ }
2988
+ return n;
2989
+ }
2990
+ function fixBounceEasing(decls) {
2991
+ return decls.replace(CUBIC_BEZIER_RE, (full, _x1, y1, _x2, y2) => bezierOvershoots(y1, y2) ? "ease-out" : full);
2992
+ }
2993
+ const LAYOUT_TRANSITION_PROP_RE = /^(?:width|height|max-width|max-height|top|left|right|bottom|inset|margin(?:-\w+)?|padding(?:-\w+)?)$/i;
2994
+ function transitionNamesLayoutProp(value) {
2995
+ return splitTopLevelCommas(value).some((seg) => LAYOUT_TRANSITION_PROP_RE.test(seg.trim().split(/\s+/)[0] ?? ""));
2996
+ }
2997
+ function countLayoutPropTransitions(html) {
2998
+ return countInCss(html, (css) => {
2999
+ if (declsAllow(css, "layout-prop-animation"))
3000
+ return 0;
3001
+ let n = 0;
3002
+ for (const m of css.matchAll(/transition(?:-property)?\s*:\s*([^;}"]*)/gi)) {
3003
+ if (transitionNamesLayoutProp(m[1]))
3004
+ n++;
3005
+ }
3006
+ return n;
3007
+ });
3008
+ }
3009
+ function countHoverScaleImage(html) {
3010
+ let n = 0;
3011
+ for (const block of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
3012
+ for (const rule of stripCssComments(block[1]).matchAll(STYLE_RULE_RE)) {
3013
+ const sel = rule[1];
3014
+ if (!/:hover/i.test(sel))
3015
+ continue;
3016
+ if (!/(?:^|[\s,.>+~#-])(?:img|image|thumb|photo|media|cover|card)/i.test(sel))
3017
+ continue;
3018
+ const scale = rule[2].match(/transform\s*:[^;}]*\bscale\(\s*([\d.]+)/i);
3019
+ if (scale && parseFloat(scale[1]) > 1)
3020
+ n++;
3021
+ }
3022
+ }
3023
+ return n;
3024
+ }
3025
+ // ---------------------------------------------------------------------------
3026
+ // UI-microcopy helpers (Gap C): deterministic copy tells, scoped to the
3027
+ // artifact's visible text only.
3028
+ // ---------------------------------------------------------------------------
3029
+ function visibleTextRuns(html) {
3030
+ const runs = [];
3031
+ eachVisibleText(html, (t) => {
3032
+ const clean = t.replace(/\s+/g, " ").trim();
3033
+ if (clean)
3034
+ runs.push(clean);
3035
+ return t;
3036
+ });
3037
+ return runs;
3038
+ }
3039
+ // Benefit-speak: the marketing verbs that sell nothing specific. "Unlock"
3040
+ // only in its marketing collocation so "Unlock with Face ID" stays legal.
3041
+ const BENEFIT_SPEAK_RE = /\b(?:elevate|supercharge|streamline|empower|effortless(?:ly)?|seamless(?:ly)?|revolutioni[sz]e|game.chang(?:er|ing)|world.class|next.level|unleash|turbocharge)\b|\bunlock (?:the|your|a|new)\b/gi;
3042
+ const NOT_X_BUT_Y_RE = /\b(?:it'?s|this is|we'?re|that'?s) not (?:just |merely |simply )?[^.!?;,]{2,48}[.;,] ?(?:it'?s|this is|it is|but) /i;
3043
+ const FABRICATED_PRECISION_RE = /\b99\.9{1,2}%|\b(?:10|100)x\b|#1\b|\btrusted by (?:[\d,.]+[km]?\+?|thousands|millions)\b/gi;
3044
+ const APOLOGETIC_ERROR_RE = /\b(?:oops|whoops|uh[- ]?oh)\b[!.]?|something went wrong/gi;
3045
+ // ---------------------------------------------------------------------------
3046
+ // Registry
3047
+ // ---------------------------------------------------------------------------
3048
+ export const FLAGSHIP_RULES = [
3049
+ {
3050
+ id: "justified-text",
3051
+ category: "quality",
3052
+ tell: "Justified text creates uneven 'rivers of white' without hyphenation.",
3053
+ prevention: "Never set text-align:justify on body copy. Use text-align:left; justified UI text creates rivers of white.",
3054
+ tier: "fix",
3055
+ severity: 1,
3056
+ detect: (html) => [...html.matchAll(/text-align\s*:\s*justify/gi)].map(() => ({ ruleId: "justified-text", detail: "text-align:justify" })),
3057
+ fix: (html) => html.replace(/(text-align\s*:\s*)justify/gi, "$1left"),
3058
+ },
3059
+ {
3060
+ id: "underlined-text",
3061
+ category: "type",
3062
+ tell: "Underlined UI text reads as a hyperlink or a typewriter document, never as polished product type.",
3063
+ prevention: "Never underline text. No text-decoration:underline, no <u> tags, and never fake an underline with a border-bottom line under a text run. Links are NOT underlined in UI; set text-decoration:none. Emphasis comes from weight, size, or color.",
3064
+ tier: "fix",
3065
+ severity: 1,
3066
+ detect: (html) => {
3067
+ const hits = [];
3068
+ for (const m of html.matchAll(/text-decoration(?:-line)?\s*:\s*[^;"'}]*\bunderline\b/gi))
3069
+ hits.push({ ruleId: "underlined-text", detail: m[0].slice(0, 40) });
3070
+ for (const _ of html.matchAll(/<\/?u(?=[\s>/])[^>]*>/gi))
3071
+ hits.push({ ruleId: "underlined-text", detail: "<u> tag" });
3072
+ return hits;
3073
+ },
3074
+ fix: (html) => html
3075
+ .replace(/text-decoration(-line)?\s*:\s*[^;"'}]*\bunderline\b[^;"'}]*/gi, "text-decoration:none")
3076
+ .replace(/<\/?u(?=[\s>/])[^>]*>/gi, ""),
3077
+ },
3078
+ {
3079
+ id: "gradient-text",
3080
+ category: "color",
3081
+ tell: "Gradient text (background-clip:text) is decorative, not meaningful; a classic AI tell on headings and metrics.",
3082
+ prevention: "Never clip a gradient into text (background-clip:text + color:transparent). Set headings/metrics in a solid color.",
3083
+ tier: "fix",
3084
+ severity: 1,
3085
+ sanctionedBy: (_styleRef, ctx) => ctx?.replicate === true,
3086
+ detect: (html) => {
3087
+ const n = eachStyleAndInline(html, isGradientTextGroup);
3088
+ return Array.from({ length: n }, () => ({ ruleId: "gradient-text", detail: "background-clip:text + transparent fill" }));
3089
+ },
3090
+ fix: (html) => rewriteCssGroups(html, isGradientTextGroup, fixGradientTextGroup),
3091
+ },
3092
+ {
3093
+ id: "hollow-text",
3094
+ category: "type",
3095
+ tell: "Hollow / outlined letterforms (color:transparent carried only by -webkit-text-stroke) render invisible where the non-standard stroke isn't painted; fails WCAG.",
3096
+ prevention: "Never set color:transparent with -webkit-text-stroke as the only thing carrying a glyph (hollow/outlined type). Use a solid fill; for a quieter read, reduce opacity.",
3097
+ tier: "fix",
3098
+ severity: 2,
3099
+ detect: (html) => {
3100
+ const n = eachStyleAndInline(html, isHollowTextGroup);
3101
+ return Array.from({ length: n }, () => ({ ruleId: "hollow-text", detail: "color:transparent + text-stroke" }));
3102
+ },
3103
+ fix: (html) => rewriteCssGroups(html, isHollowTextGroup, fixHollowTextGroup),
3104
+ },
3105
+ {
3106
+ id: "indigo-accent",
3107
+ category: "color",
3108
+ tell: "Tailwind indigo/violet (#6366f1, #8b5cf6, #7c3aed ...) as the accent: the single most common generated-UI fingerprint color.",
3109
+ prevention: "Never default to indigo/violet (#6366f1, #818cf8, #8b5cf6, #7c3aed ...) as the accent unless the brief explicitly asks for it. Use the brand accent.",
3110
+ tier: "fix",
3111
+ severity: 1,
3112
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasIndigo) }, () => ({ ruleId: "indigo-accent", detail: "Tailwind indigo/violet hex" })),
3113
+ fix: (html) => rewriteCssGroups(html, groupHasIndigo, fixIndigoGroup),
3114
+ },
3115
+ {
3116
+ id: "heavy-box-shadow",
3117
+ category: "visual",
3118
+ tell: "A heavy or stacked drop-shadow (multi-layer ambient, or a single blurred layer at alpha > 0.30) under a routine card: the 'puffy floating card' generated-UI signature.",
3119
+ prevention: "Never stack multiple drop-shadows or use a heavy ambient shadow (alpha > 0.3) on routine cards. Use ONE subtle elevation or rely on surface-tone contrast.",
3120
+ tier: "fix",
3121
+ severity: 2,
3122
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasSlopShadow) }, () => ({ ruleId: "heavy-box-shadow", detail: "stacked / heavy drop-shadow" })),
3123
+ fix: (html) => rewriteCssGroups(html, groupHasSlopShadow, flattenSlopShadows),
3124
+ },
3125
+ {
3126
+ id: "gradient-border",
3127
+ category: "visual",
3128
+ tell: "A multi-stop gradient ring around a thumbnail/avatar/card (border-image: linear-gradient): decorative chrome and a loud generated-UI tell.",
3129
+ prevention: "Never wrap a thumbnail, avatar, image, card, or pill in a gradient border/ring. The element's own rounded corners ARE the finish; add nothing around them.",
3130
+ tier: "fix",
3131
+ severity: 1,
3132
+ detect: (html) => [...html.matchAll(GRADIENT_BORDER_RE)].map(() => ({ ruleId: "gradient-border", detail: "border-image gradient ring" })),
3133
+ fix: (html) => html.replace(GRADIENT_BORDER_STRIP_RE, ""),
3134
+ },
3135
+ {
3136
+ id: "bare-hr",
3137
+ category: "visual",
3138
+ tell: "A bare <hr> renders as a full-opacity 3D divider; hairline dividers read cleaner.",
3139
+ prevention: "Never use a bare <hr>. Separate sections with spacing/typography, or a hairline (alpha <= 0.08) divider.",
3140
+ tier: "fix",
3141
+ severity: 1,
3142
+ detect: (html) => [...html.matchAll(/<hr\b([^>]*)>/gi)]
3143
+ .filter((m) => !/border/i.test(m[1]) && !tagAllows(m[1], "bare-hr"))
3144
+ .map(() => ({ ruleId: "bare-hr", detail: "<hr> with no border style" })),
3145
+ fix: (html) => html.replace(/<hr\b([^>]*)>/gi, (full, attrs) => {
3146
+ if (/border/i.test(attrs) || tagAllows(attrs, "bare-hr"))
3147
+ return full;
3148
+ const hair = "border:none;border-top:1px solid rgba(0,0,0,0.08);";
3149
+ const styleM = attrs.match(/\sstyle\s*=\s*"([^"]*)"/i);
3150
+ if (styleM) {
3151
+ const merged = `${styleM[1].replace(/;?\s*$/, "")}; ${hair}`;
3152
+ return `<hr${attrs.replace(styleM[0], ` style="${merged}"`)}>`;
3153
+ }
3154
+ return `<hr${attrs} style="${hair}">`;
3155
+ }),
3156
+ },
3157
+ {
3158
+ id: "decorative-divider",
3159
+ category: "visual",
3160
+ tell: "Box-drawing characters or em-dash runs used as a decorative rule between labels: chrome that conveys nothing.",
3161
+ prevention: "Never use box-drawing characters or dash runs as visual dividers between labels or sections. Separate with whitespace, a low-alpha hairline, or surface-tone contrast.",
3162
+ tier: "fix",
3163
+ severity: 1,
3164
+ detect: (html) => detectDecorativeDividers(html),
3165
+ fix: (html) => eachVisibleText(html, (t) => t.replace(BOXRUN_RE, "").replace(DASHRUN_RE, "")),
3166
+ },
3167
+ {
3168
+ id: "missing-lang",
3169
+ category: "quality",
3170
+ tell: "<html> without a lang attribute breaks screen-reader language detection.",
3171
+ prevention: 'Always set lang on the <html> element (e.g. <html lang="en">).',
3172
+ tier: "fix",
3173
+ severity: 1,
3174
+ detect: (html) => {
3175
+ const m = html.match(/<html\b([^>]*)>/i);
3176
+ return m && !/\blang\s*=/i.test(m[1])
3177
+ ? [{ ruleId: "missing-lang", detail: "<html> has no lang" }]
3178
+ : [];
3179
+ },
3180
+ fix: (html) => html.replace(/<html\b([^>]*)>/i, (full, attrs) => /\blang\s*=/i.test(attrs) ? full : `<html lang="en"${attrs}>`),
3181
+ },
3182
+ {
3183
+ id: "all-caps-body",
3184
+ category: "type",
3185
+ tell: "Long passages in uppercase are hard to read; we recognize words by ascender/descender shape.",
3186
+ prevention: "Never set text-transform:uppercase on long body passages. Reserve all-caps for short labels (a few words at most).",
3187
+ tier: "fix",
3188
+ severity: 1,
3189
+ detect: (html) => [...html.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/gi)]
3190
+ .filter((m) => /text-transform\s*:\s*uppercase/i.test(m[1]) &&
3191
+ visibleLen(m[2]) > 60 &&
3192
+ !tagAllows(m[1], "all-caps-body"))
3193
+ .map(() => ({ ruleId: "all-caps-body", detail: "<p> uppercase, >60 chars" })),
3194
+ fix: (html) => html.replace(/<p\b([^>]*)>([\s\S]*?)<\/p>/gi, (full, attrs, inner) => {
3195
+ if (!/text-transform\s*:\s*uppercase/i.test(attrs) ||
3196
+ visibleLen(inner) <= 60 ||
3197
+ tagAllows(attrs, "all-caps-body"))
3198
+ return full;
3199
+ const newAttrs = attrs.replace(/text-transform\s*:\s*uppercase\s*;?/i, "");
3200
+ return `<p${newAttrs}>${inner}</p>`;
3201
+ }),
3202
+ },
3203
+ {
3204
+ id: "broken-image",
3205
+ category: "imagery",
3206
+ tell: "<img> with empty, missing, or placeholder src ships as a broken-image box.",
3207
+ prevention: "Never emit an <img> with empty/missing/placeholder src. Use a real asset or omit the element.",
3208
+ tier: "fix",
3209
+ severity: 1,
3210
+ detect: (html) => [...html.replace(RAW_REGION_RE, "").matchAll(IMG_TAG_RE)]
3211
+ .filter((m) => isBrokenImg(m[0]))
3212
+ .map(() => ({ ruleId: "broken-image", detail: "<img> with empty/placeholder src" })),
3213
+ fix: (html) => outsideRawRegions(html, (c) => c.replace(IMG_TAG_RE, (tag) => (isBrokenImg(tag) ? "" : tag))),
3214
+ },
3215
+ {
3216
+ id: "emoji-icon",
3217
+ category: "type",
3218
+ tell: "A leading emoji used as an icon glyph on a label / nav item / heading / button: an icon system is the real affordance.",
3219
+ prevention: "Never use an emoji as an icon or leading glyph on a label, nav item, heading, or button. Use a real icon or no glyph.",
3220
+ tier: "fix",
3221
+ severity: 1,
3222
+ detect: (html) => [...html.matchAll(emojiIconRe())]
3223
+ .filter((m) => !tagAllows(m[1], "emoji-icon"))
3224
+ .map(() => ({ ruleId: "emoji-icon", detail: "leading emoji on a label/nav/heading" })),
3225
+ fix: (html) => html.replace(emojiIconRe(), (full, tag) => tagAllows(tag, "emoji-icon") ? full : tag),
3226
+ },
3227
+ {
3228
+ id: "cents-suffix",
3229
+ category: "copy",
3230
+ tell: "A price split into a base + a smaller cents/decimal suffix span makes a mockup read as a live screen-recording.",
3231
+ prevention: "Use whole-number mockup data. Never split a price into a base + a smaller cents suffix span (e.g. $28,461 then a .20 span).",
3232
+ tier: "fix",
3233
+ severity: 1,
3234
+ detect: (html) => Array.from({ length: [...html.matchAll(centsRe())].length }, () => ({ ruleId: "cents-suffix", detail: "cents/decimal suffix span" })),
3235
+ fix: (html) => fixCents(html),
3236
+ },
3237
+ {
3238
+ id: "oversized-number",
3239
+ category: "copy",
3240
+ tell: "A long un-abbreviated figure (e.g. $1,842,000) overflows its container and reads as a live screen-recording rather than mockup data.",
3241
+ prevention: "Keep every numeric value <= 9,999 as raw digits. Abbreviate any magnitude >= 10,000 to K/M/B with at most one decimal (1,842,000 -> 1.8M); never print a raw figure longer than four digits.",
3242
+ tier: "fix",
3243
+ severity: 1,
3244
+ detect: (html) => detectOversizedNumbers(html),
3245
+ fix: (html) => fixOversizedNumbers(html),
3246
+ },
3247
+ {
3248
+ id: "transition-all",
3249
+ category: "motion",
3250
+ tell: "transition: all animates every mutable property (layout, color, shadow) instead of naming the one that should move: the lazy default behind janky, accidental motion.",
3251
+ prevention: "Never write transition: all (or transition-property: all). Name the exact properties to animate, e.g. transition: transform 200ms ease-out, opacity 200ms ease-out.",
3252
+ tier: "gate",
3253
+ severity: 1,
3254
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasTransitionAll) }, () => ({ ruleId: "transition-all", detail: "transition: all" })),
3255
+ },
3256
+ {
3257
+ id: "em-dash-copy",
3258
+ category: "copy",
3259
+ tell: "An em dash mid-sentence is the single most recognizable generated-TEXT tell; polished interface copy uses commas, colons, and periods.",
3260
+ prevention: "Never use an em dash (or a spaced en dash) in interface copy. Use a comma, colon, semicolon, or period. Unspaced en-dash ranges (Mon-Fri, 1-5) are fine.",
3261
+ tier: "fix",
3262
+ severity: 1,
3263
+ detect: (html) => {
3264
+ const hits = [];
3265
+ eachVisibleText(html, (text) => {
3266
+ const n = countEmDashes(text);
3267
+ for (let i = 0; i < n; i++)
3268
+ hits.push({ ruleId: "em-dash-copy", detail: "em dash in copy" });
3269
+ return text; // detect-only: never mutate
3270
+ });
3271
+ return hits;
3272
+ },
3273
+ fix: (html) => eachVisibleText(html, fixEmDashes),
3274
+ },
3275
+ {
3276
+ id: "lorem-ipsum",
3277
+ category: "copy",
3278
+ tell: "Lorem-ipsum filler in a finished screen reads as an abandoned template; realistic domain copy is what makes a mockup feel designed.",
3279
+ prevention: "Never emit lorem-ipsum filler. Write short, realistic copy for the product's actual domain (no fix can invent it for you).",
3280
+ tier: "gate",
3281
+ severity: 2,
3282
+ detect: (html) => {
3283
+ const hits = [];
3284
+ eachVisibleText(html, (text) => {
3285
+ for (const m of text.matchAll(new RegExp(LOREM_SRC, "gi")))
3286
+ hits.push({ ruleId: "lorem-ipsum", detail: `"${m[0]}"` });
3287
+ return text; // detect-only: never mutate
3288
+ });
3289
+ return hits;
3290
+ },
3291
+ },
3292
+ {
3293
+ id: "missing-alt",
3294
+ category: "imagery",
3295
+ tell: '<img> without an alt attribute makes a screen reader announce the raw filename; even alt="" (decorative) is a decision, silence is not.',
3296
+ prevention: 'Give every <img> an alt attribute: a short content description for meaningful images, alt="" for purely decorative ones.',
3297
+ tier: "fix",
3298
+ severity: 1,
3299
+ detect: (html) => [...html.replace(RAW_REGION_RE, "").matchAll(IMG_TAG_RE)]
3300
+ .filter((m) => isAltlessImg(m[0]))
3301
+ .map(() => ({ ruleId: "missing-alt", detail: "<img> with no alt" })),
3302
+ fix: (html) => outsideRawRegions(html, (c) => c.replace(IMG_TAG_RE, (tag) => isAltlessImg(tag) ? tag.replace(/\s*(\/?)>$/, ' alt=""$1>') : tag)),
3303
+ },
3304
+ {
3305
+ id: "placeholder-image",
3306
+ category: "imagery",
3307
+ tell: "A stock placeholder-service URL (pravatar, placehold.co, picsum ...) shipped as a real asset: the screen was never finished with real imagery.",
3308
+ prevention: "Never ship placeholder-service imagery (i.pravatar.cc, ui-avatars.com, placehold.co, picsum.photos ...). Use a real asset or drop the image; no fix can choose one for you.",
3309
+ tier: "gate",
3310
+ severity: 1,
3311
+ detect: (html) => [...html.replace(RAW_REGION_RE, "").matchAll(IMG_TAG_RE)]
3312
+ .filter((m) => isPlaceholderServiceImg(m[0]))
3313
+ .map(() => ({
3314
+ ruleId: "placeholder-image",
3315
+ detail: "placeholder-service src",
3316
+ })),
3317
+ },
3318
+ // ---- color: entity gradient fills ----------------------------------------
3319
+ {
3320
+ id: "gradient-fill",
3321
+ category: "color",
3322
+ tell: "A rounded entity (icon tile, card, chip, avatar, filled button) filled with a gradient: a single solid color reads cleaner; the gradient is a generated-UI tell.",
3323
+ prevention: "Never fill an entity (icon tile, card, chip, avatar, filled button) with a gradient: no linear/radial/conic-gradient as its background. A single solid color is the fill, even when the visual reference uses a gradient.",
3324
+ tier: "fix",
3325
+ severity: 1,
3326
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, isSingleEntityFill) }, () => ({ ruleId: "gradient-fill", detail: "gradient entity fill, flattened to solid" })),
3327
+ fix: (html) => rewriteCssGroups(html, isSingleEntityFill, (d) => fixEntityFill(d, "single")),
3328
+ },
3329
+ {
3330
+ id: "multicolor-fill",
3331
+ category: "color",
3332
+ tell: "Multiple competing hues across an entity's background (a two-color gradient on an icon tile/card): a loud generated-UI tell.",
3333
+ prevention: "Never blend multiple hues across an entity's background (e.g. a pink-to-purple or orange-to-pink gradient on an icon tile/card). Fill it with ONE muted neutral surface tone, never two competing colors.",
3334
+ tier: "fix",
3335
+ severity: 1,
3336
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, isMultiEntityFill) }, () => ({ ruleId: "multicolor-fill", detail: "multi-hue entity fill, muted" })),
3337
+ fix: (html) => rewriteCssGroups(html, isMultiEntityFill, (d) => fixEntityFill(d, "multi")),
3338
+ },
3339
+ {
3340
+ id: "repeating-gradient-stripe",
3341
+ category: "visual",
3342
+ tell: "Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature.",
3343
+ prevention: "Never use repeating-linear/radial-gradient stripes as surface decoration. Use a flat fill or a deliberate, meaningful pattern.",
3344
+ tier: "fix",
3345
+ severity: 1,
3346
+ detect: (html) => [...html.matchAll(/repeating-(?:linear|radial|conic)-gradient\s*\(/gi)].map(() => ({ ruleId: "repeating-gradient-stripe", detail: "repeating-*-gradient()" })),
3347
+ fix: (html) => replaceBalanced(html, /repeating-(?:linear|radial|conic)-gradient\s*\(/gi, (_full, inner) => firstColor(inner) ?? "transparent").out,
3348
+ },
3349
+ // ---- dataviz honesty ------------------------------------------------------
3350
+ {
3351
+ id: "fake-dot-viz",
3352
+ category: "visual",
3353
+ tell: "A row of equal colored dots/nodes faking a 'momentum'/'pulse' chart conveys nothing and collides with the value column.",
3354
+ prevention: "Never fake a chart with a cluster of equal dots/nodes in a list row. Use a real sparkline or a plain number + delta.",
3355
+ tier: "fix",
3356
+ severity: 2,
3357
+ detect: (html) => [...html.matchAll(DOT_CLUSTER_RE)].map(() => ({ ruleId: "fake-dot-viz", detail: ">=3 empty dot/node cluster in a viz-ish wrapper" })),
3358
+ fix: (html) => html.replace(DOT_CLUSTER_RE, ""),
3359
+ },
3360
+ {
3361
+ id: "viz-stray-ticks",
3362
+ category: "visual",
3363
+ tell: "Short radiating tick <line>s sprayed on a gauge/arc (e.g. 7 unlabeled 8px segments around a rating dial): decorative chrome that encodes nothing and reads as the strongest generated-gauge tell.",
3364
+ prevention: "Never draw decorative tick marks on a gauge or arc. A rating/score gauge needs ONLY the arc, the value, and (at most) two endpoint labels: no radiating <line> ticks around the rim. They are unlabeled chrome and convey nothing.",
3365
+ tier: "fix",
3366
+ severity: 2,
3367
+ detect: (html) => findStrayVizTicks(parse(html)).map(() => ({ ruleId: "viz-stray-ticks", detail: "short ungrouped tick <line> on a gauge/arc" })),
3368
+ fix: (html) => fixStrayVizTicks(html),
3369
+ },
3370
+ {
3371
+ id: "viz-redundant-scale",
3372
+ category: "copy",
3373
+ tell: "A gauge states its scale twice: a '/N' suffix on the value (7/10) AND separate '0' and 'N' endpoint labels on the arc. The endpoints are redundant once the value carries the denominator.",
3374
+ prevention: "State a gauge's scale ONCE. If the value already shows a denominator (7/10, 68%), do NOT also label the arc endpoints with 0 and N: the endpoints are redundant. Use descriptive endpoint labels (TODAY / GOAL) or none.",
3375
+ tier: "fix",
3376
+ severity: 1,
3377
+ detect: (html) => findRedundantScaleTexts(parse(html), collectClassDecls(html)).map((t) => ({
3378
+ ruleId: "viz-redundant-scale",
3379
+ detail: `redundant endpoint "${(t.text ?? "").trim()}"`,
3380
+ })),
3381
+ fix: (html) => fixRedundantScaleTexts(html),
3382
+ },
3383
+ {
3384
+ id: "glyph-on-metric",
3385
+ category: "visual",
3386
+ tell: "A decorative emoji, catalog icon, or trend glyph placed on or over a numeric value: an emoji centered behind a progress-ring's percentage, an arrow or icon beside a stat's NN%. The number is the visual; the glyph collides with or pollutes it.",
3387
+ prevention: "Never place a decorative emoji, catalog icon, illustration, mascot, or blob on, over, behind, or beside a numeric value (a KPI, percentage, stat, score, progress-ring/arc value, gauge, or dial reading). The number IS the imagery. For a ring/arc/gauge the only marks are the arc, the value, and at most two endpoint labels. In any stat unit that shows a percentage, render the number and its text label alone: no leading emoji, no trend-arrow glyph, no icon chip beside the value.",
3388
+ tier: "fix",
3389
+ severity: 2,
3390
+ detect: (html) => detectGlyphOnMetric(html),
3391
+ fix: (html) => fixGlyphOnMetric(html),
3392
+ },
3393
+ {
3394
+ id: "stat-label-icon",
3395
+ category: "visual",
3396
+ tell: "A number-over-category stat tile whose category label is prefixed with an icon: the label word already names the category, so the leading icon duplicates information and adds visual noise.",
3397
+ prevention: "In a stat/metric/filter tile that pairs a number with a category label, never prefix the category label with an icon: the word already names the category, so a leading icon is duplicate information and visual pollution. Show just the number + the text label.",
3398
+ tier: "fix",
3399
+ severity: 1,
3400
+ detect: (html) => findStatLabelIcons(parse(html)).map((lead) => ({
3401
+ ruleId: "stat-label-icon",
3402
+ detail: `leading icon on "${(lead.parentNode?.text ?? "").replace(/\s+/g, " ").trim().slice(0, 20)}"`,
3403
+ })),
3404
+ fix: (html) => {
3405
+ const root = parse(html);
3406
+ const leads = findStatLabelIcons(root);
3407
+ if (leads.length === 0)
3408
+ return html;
3409
+ for (const lead of leads)
3410
+ lead.remove();
3411
+ return root.toString();
3412
+ },
3413
+ },
3414
+ {
3415
+ id: "live-clock-eyebrow",
3416
+ category: "copy",
3417
+ tell: "A 'LIVE'/'NOW' dot badge and/or the current wall-clock time shown as a UI eyebrow: the device status bar already shows the time, and the live dot is decorative chrome.",
3418
+ prevention: "Never show the current wall-clock time in UI content: the device status bar already displays it. Never use a 'LIVE'/'NOW' dot badge, with or without a trailing clock. Drop the whole eyebrow.",
3419
+ tier: "fix",
3420
+ severity: 1,
3421
+ detect: (html) => findLiveEyebrows(parse(html)).map((el) => ({
3422
+ ruleId: "live-clock-eyebrow",
3423
+ detail: `"${el.text.replace(/\s+/g, " ").trim().slice(0, 24)}"`,
3424
+ })),
3425
+ fix: (html) => {
3426
+ const root = parse(html);
3427
+ const eyebrows = findLiveEyebrows(root);
3428
+ if (eyebrows.length === 0)
3429
+ return html;
3430
+ for (const el of eyebrows)
3431
+ el.remove();
3432
+ return root.toString();
3433
+ },
3434
+ },
3435
+ // ---- layout collapse bugs ---------------------------------------------------
3436
+ {
3437
+ id: "floating-hero-card",
3438
+ category: "layout",
3439
+ tell: "Decorative 'badge' / 'spec' cards floated over the hero: corner-pinned, backdrop-blurred chips carrying a tiny label that overlap the artwork. A generated-landing signature.",
3440
+ prevention: "Never float decorative badge / spec cards over a hero or banner: small absolutely / fixed-positioned, corner-pinned chips with a backdrop-blur fill and a tiny label / value. They overlap the artwork and read as generated chrome. Keep hero content in ONE column; if a detail matters, place it inline, not in a floating card.",
3441
+ tier: "fix",
3442
+ severity: 1,
3443
+ detect: (html) => findFloatingHeroCards(parse(html), collectClassDecls(html)).map((el) => ({
3444
+ ruleId: "floating-hero-card",
3445
+ detail: `"${el.text.replace(/\s+/g, " ").trim().slice(0, 28)}"`,
3446
+ })),
3447
+ fix: (html) => {
3448
+ const root = parse(html);
3449
+ const cards = findFloatingHeroCards(root, collectClassDecls(html));
3450
+ if (cards.length === 0)
3451
+ return html;
3452
+ for (const el of cards)
3453
+ el.remove();
3454
+ return root.toString();
3455
+ },
3456
+ },
3457
+ {
3458
+ id: "grid-spacer-void",
3459
+ category: "layout",
3460
+ tell: "A full-span hairline row divider (grid-column: 1 / -1; height: 1px) placed inside a grid with a fixed grid-auto-rows (e.g. 168px) lands in its own tall track: a 1px line with a ~167px empty band below it, so the screen reads as content rows separated by giant voids.",
3461
+ prevention: "Never place a full-span divider (grid-column: 1 / -1 with a small fixed height) as a child of a grid that sets a fixed grid-auto-rows / grid-template-rows height: the 1px line is stretched to a whole row, leaving a tall empty band below it. Separate rows with a border-top/border-bottom on the cells themselves, or set grid-auto-rows to auto so each row sizes to its content.",
3462
+ tier: "fix",
3463
+ severity: 2,
3464
+ detect: (html) => [...gridSpacerVoidClasses(html)].map((c) => ({
3465
+ ruleId: "grid-spacer-void",
3466
+ detail: `.${c}: fixed grid-auto-rows with a full-span hairline child`,
3467
+ })),
3468
+ fix: (html) => fixGridSpacerVoid(html),
3469
+ },
3470
+ {
3471
+ id: "wrap-padding-collision",
3472
+ category: "layout",
3473
+ tell: "A section carries the page's inset container class (max-width + margin-inline:auto + padding-inline) but ALSO zeroes its own horizontal padding (a `padding: V 0` shorthand, or padding-left/right:0). At equal specificity the later rule wins, clobbering the container's padding-inline, so the band's content runs flush to the screen edge.",
3474
+ prevention: "Never set horizontal padding to 0 on an element that also carries the page's inset / `.wrap` container class. Give a section its vertical rhythm with `padding-block` (never `padding: V 0`, padding-left/right:0, or padding-inline:0), so the container's padding-inline survives. If a band is intentionally full-bleed, omit the container class instead of zeroing its padding.",
3475
+ tier: "fix",
3476
+ severity: 2,
3477
+ detect: (html) => [...wrapPaddingCollisionClasses(html)].map((c) => ({
3478
+ ruleId: "wrap-padding-collision",
3479
+ detail: `.${c} zeroes the inset container's horizontal padding`,
3480
+ })),
3481
+ fix: (html) => fixWrapPaddingCollision(html),
3482
+ },
3483
+ {
3484
+ id: "body-display-contents",
3485
+ category: "layout",
3486
+ tell: "display:contents on <body> makes it generate no box: its padding, width, and flex gap are ALL discarded, so the screen renders with no side padding, content under the status bar, and zero section spacing.",
3487
+ prevention: "Never set display:contents on <body>, inline or via a body{} rule. A boxless body discards its padding and flex gap and the whole screen collapses; body must keep its own box. (display:contents is fine on a nested wrapper <div>, never on body.)",
3488
+ tier: "fix",
3489
+ severity: 2,
3490
+ detect: (html) => Array.from({ length: bodyDisplayContentsCount(html) }, () => ({
3491
+ ruleId: "body-display-contents",
3492
+ detail: "display:contents on <body> discards its padding and gap",
3493
+ })),
3494
+ fix: (html) => bodyDisplayContentsCount(html) > 0 ? stripBodyDisplayContents(html) : html,
3495
+ },
3496
+ // ---- base polish (additive; absence is not a defect) ------------------------
3497
+ {
3498
+ id: "hscroll-snap-gutter",
3499
+ category: "layout",
3500
+ tell: "A horizontal scroll-snap carousel whose side gutter is the container's own inline padding (overflow-x:auto; scroll-snap-type:x mandatory; padding:0 16px) loses that gutter: scroll-snap-align:start rests the first card flush to the edge because the padding sits outside the snapport.",
3501
+ prevention: "For a horizontal scroll-snap carousel whose side gutter is the container's OWN inline padding (overflow-x:auto with scroll-snap-type:x and padding:0 16px), ALSO set scroll-padding-inline to that same gutter (e.g. scroll-padding-inline:16px). Without it, scroll-snap-align:start under mandatory snap rests the first card flush to the edge, so the first card loses its left spacing and the last card collides with the right edge. Alternatively carry the gutter on the cards with scroll-margin-inline instead of container padding.",
3502
+ tier: "base",
3503
+ severity: 1,
3504
+ detect: (html) => Array.from({ length: countLeakyHScrollSnap(html) }, () => ({
3505
+ ruleId: "hscroll-snap-gutter",
3506
+ detail: "h-scroll snap container insets cards with padding but sets no scroll-padding",
3507
+ })),
3508
+ fix: (html) => rewriteCssGroups(html, isLeakyHScrollSnapGroup, addScrollPaddingInline),
3509
+ },
3510
+ {
3511
+ id: "text-wrap-orphans",
3512
+ category: "type",
3513
+ tell: "Headings ragging unevenly and body copy stranding a one-word orphan on the last line read as unpolished: text-wrap:balance / pretty fix it for free.",
3514
+ prevention: "Set text-wrap:balance on headings (h1-h3) so line lengths even out and no orphan strands, and text-wrap:pretty on body copy (p, li, figcaption, blockquote) so the last line never leaves a single word alone. Skip both on long passages (10+ lines), code, and <pre>.",
3515
+ tier: "base",
3516
+ severity: 1,
3517
+ detect: (html) => isFullDocument(html) &&
3518
+ !hasPolishBlock(html, "gesso-text-wrap") &&
3519
+ /<(?:h[1-3]|p|li|figcaption|blockquote)\b/i.test(html)
3520
+ ? [{ ruleId: "text-wrap-orphans", detail: "no balance/pretty wrapping" }]
3521
+ : [],
3522
+ fix: (html) => injectPolishBlock(html, "gesso-text-wrap", "h1,h2,h3{text-wrap:balance}p,li,figcaption,blockquote{text-wrap:pretty}"),
3523
+ },
3524
+ {
3525
+ id: "font-smoothing",
3526
+ category: "type",
3527
+ tell: "Default macOS text rendering is too heavy; without root antialiasing, type looks bolder than the design intends.",
3528
+ prevention: "Apply -webkit-font-smoothing:antialiased and -moz-osx-font-smoothing:grayscale ONCE at the root (html). It lightens over-heavy macOS rendering and is safe everywhere (non-macOS ignores it). Never set it per-element.",
3529
+ tier: "base",
3530
+ severity: 1,
3531
+ detect: (html) => isFullDocument(html) && !hasPolishBlock(html, "gesso-font-smoothing")
3532
+ ? [{ ruleId: "font-smoothing", detail: "no root font-smoothing" }]
3533
+ : [],
3534
+ fix: (html) => injectPolishBlock(html, "gesso-font-smoothing", "html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}"),
3535
+ },
3536
+ {
3537
+ id: "image-outline",
3538
+ category: "imagery",
3539
+ tell: "A photo dropped flush on the surface has no edge: light image regions bleed into light backgrounds and the layout loses its shape.",
3540
+ prevention: "Define every content image's edge with a 1px inset hairline: outline:1px solid rgba(0,0,0,0.05) on light pages (white at 5% on dark), outline-offset:-1px. Outline, not border, so layout never shifts; pure black/white only, since a tinted edge reads as dirt.",
3541
+ tier: "base",
3542
+ severity: 1,
3543
+ detect: (html) => isFullDocument(html) && !hasPolishBlock(html, "gesso-image-outline") && /<img\b/i.test(html)
3544
+ ? [{ ruleId: "image-outline", detail: "images have no inset edge hairline" }]
3545
+ : [],
3546
+ fix: (html) => isFullDocument(html) && /<img\b/i.test(html)
3547
+ ? injectPolishBlock(html, "gesso-image-outline", `img:not([data-illustration]):not([data-icon]):not([aria-hidden="true"]){outline:1px solid ${pageImageOutlineColor(html)};outline-offset:-1px}`)
3548
+ : html,
3549
+ },
3550
+ // ---- print-chrome copy artifacts ------------------------------------------
3551
+ // publication-masthead-block MUST run before masthead-eyebrow: the eyebrow
3552
+ // rule (element-whole-text) would strip the inner "VOL. 04 / 2024" span
3553
+ // first, dropping this block's tell count below threshold and orphaning the
3554
+ // rest. Removing the whole container first leaves nothing to nibble.
3555
+ {
3556
+ id: "publication-masthead-block",
3557
+ category: "copy",
3558
+ tell: "An invented print-metadata cluster parked at the top of the page: VOLUME 04, CATALOGUE HV-IDX-029, UPDATED 14 MAR. The product has no volume or catalogue; the block exists to look editorial.",
3559
+ prevention: "Never invent publication metadata. No VOLUME / ISSUE / EDITION / CATALOGUE / SERIAL / UPDATED clusters with made-up numbers, serial codes, or dates: a screen is not a magazine issue.",
3560
+ tier: "fix",
3561
+ severity: 2,
3562
+ detect: (html) => findPublicationMastheadBlocks(parse(html)).map((el) => ({
3563
+ ruleId: "publication-masthead-block",
3564
+ detail: `"${el.text.replace(/\s+/g, " ").trim().slice(0, 32)}"`,
3565
+ })),
3566
+ fix: (html) => {
3567
+ const root = parse(html);
3568
+ const blocks = findPublicationMastheadBlocks(root);
3569
+ if (blocks.length === 0)
3570
+ return html;
3571
+ for (const el of blocks)
3572
+ el.remove();
3573
+ return root.toString();
3574
+ },
3575
+ },
3576
+ {
3577
+ id: "masthead-eyebrow",
3578
+ category: "copy",
3579
+ tell: "A lone magazine-issue label ('VOL. 04, № 27', 'ISSUE 12') worn as an eyebrow: borrowed print chrome on a product that ships no issues.",
3580
+ prevention: "No VOL. / ISSUE / № / EDITION eyebrows. Software has versions and dates, not issues; drop the label entirely.",
3581
+ tier: "fix",
3582
+ severity: 1,
3583
+ detect: (html) => findEyebrowMatches(parse(html), MASTHEAD_EYEBROW_TEXT)
3584
+ .filter((el) => !elAllows(el, "masthead-eyebrow"))
3585
+ .map((el) => ({
3586
+ ruleId: "masthead-eyebrow",
3587
+ detail: `"${el.text.replace(/\s+/g, " ").trim().slice(0, 24)}"`,
3588
+ })),
3589
+ fix: (html) => {
3590
+ const root = parse(html);
3591
+ const hits = findEyebrowMatches(root, MASTHEAD_EYEBROW_TEXT).filter((el) => !elAllows(el, "masthead-eyebrow"));
3592
+ if (hits.length === 0)
3593
+ return html;
3594
+ for (const el of hits)
3595
+ el.remove();
3596
+ return root.toString();
3597
+ },
3598
+ },
3599
+ {
3600
+ id: "hero-kicker-eyebrow",
3601
+ category: "layout",
3602
+ tell: "The badge above the H1: a tiny uppercase, letter-spaced kicker restating or locating the headline it sits on. The single most-cited generated-landing tell.",
3603
+ prevention: "The hero headline stands alone. No uppercase tracked kicker directly above the H1, and no standalone label band before the hero section; if the kicker says anything worth keeping, fold it into the headline or the body. (Section headers above a distinct list further down are fine.)",
3604
+ tier: "fix",
3605
+ severity: 1,
3606
+ detect: (html) => findHeroEyebrows(parse(html), collectClassDecls(html)).map((k) => ({
3607
+ ruleId: "hero-kicker-eyebrow",
3608
+ detail: `"${k.text.replace(/\s+/g, " ").trim().slice(0, 28)}"`,
3609
+ })),
3610
+ fix: (html) => fixHeroKicker(html),
3611
+ },
3612
+ // ---- redundant chrome -------------------------------------------------------
3613
+ {
3614
+ id: "edge-stripe",
3615
+ category: "visual",
3616
+ tell: "The colored rail: a thick border-left (or right) accent stripe on cards and rows, usually repeated down the list as ersatz category coding.",
3617
+ prevention: "No decorative edge stripes: never encode category or status with a >=3px colored border-left/right down a list. Use surface tone, spacing, or a labeled chip. Exception: ONE selected/[aria-selected] row may carry a 3-4px leading accent as its selection state.",
3618
+ tier: "fix",
3619
+ severity: 1,
3620
+ detect: (html) => Array.from({ length: countEdgeStripes(html) }, () => ({ ruleId: "edge-stripe", detail: "border-left/right >=3px colored rail" })),
3621
+ fix: (html) => fixEdgeStripes(html),
3622
+ },
3623
+ {
3624
+ id: "redundant-border",
3625
+ category: "visual",
3626
+ tell: "Belt and suspenders: an opaque box border drawn around an element that already separates itself with a background fill.",
3627
+ prevention: "Fill OR border, never both. A filled card, tile, or button needs no outline; if the fill isn't enough separation, adjust the surface tones instead of boxing it. (Form fields, tables, code blocks, and state borders are exempt; hairlines at <=10% alpha are fine.)",
3628
+ tier: "fix",
3629
+ severity: 1,
3630
+ detect: (html) => Array.from({ length: countRedundantBorders(html) }, () => ({ ruleId: "redundant-border", detail: "border on a filled card" })),
3631
+ fix: (html) => fixRedundantBorders(html),
3632
+ },
3633
+ // ---- headline consistency ---------------------------------------------------
3634
+ {
3635
+ id: "multicolor-heading",
3636
+ category: "color",
3637
+ tell: "The two-tone headline: a few words dipped in the accent color while the rest stays neutral, the default move for faking emphasis.",
3638
+ prevention: "A headline is one ink. Emphasize with weight, size, or a line break, never by recoloring part of the sentence. (Gradient-clipped text has its own guard.)",
3639
+ tier: "fix",
3640
+ severity: 1,
3641
+ sanctionedBy: (_styleRef, ctx) => ctx?.replicate === true,
3642
+ detect: (html) => findMulticolorHeadings(parse(html), collectClassDecls(html)).map(({ heading }) => ({
3643
+ ruleId: "multicolor-heading",
3644
+ detail: `"${heading.text.replace(/\s+/g, " ").trim().slice(0, 28)}"`,
3645
+ })),
3646
+ fix: (html) => fixMulticolorHeadings(html),
3647
+ },
3648
+ {
3649
+ id: "mixed-style-headline",
3650
+ category: "type",
3651
+ tell: "The mid-sentence italic swerve: a headline that starts upright and finishes italic (or vice versa) to manufacture sophistication.",
3652
+ prevention: "A headline keeps one type style end to end. A fully italic headline is a choice; an upright one that turns italic halfway is a tell. Emphasize with weight, size, or a line break.",
3653
+ tier: "fix",
3654
+ severity: 1,
3655
+ sanctionedBy: (_styleRef, ctx) => ctx?.replicate === true,
3656
+ detect: (html) => findMixedStyleHeadings(parse(html), collectClassDecls(html)).map(({ heading }) => ({
3657
+ ruleId: "mixed-style-headline",
3658
+ detail: `"${heading.text.replace(/\s+/g, " ").trim().slice(0, 28)}"`,
3659
+ })),
3660
+ fix: (html) => fixMixedStyleHeadings(html),
3661
+ },
3662
+ // ---- over-designed list rows (app-feed genre; advisory on marketing pages) --
3663
+ {
3664
+ id: "row-kicker-eyebrow",
3665
+ category: "layout",
3666
+ tell: "List rows wearing eyebrows: every repeated item opens with an ALL-CAPS kicker ('LOCAL FAVORITE · 96 RAVING') before its actual title.",
3667
+ prevention: "Inside a repeating list row, the title comes first. Status goes in one trailing meta value, not a kicker stacked above the title; save eyebrows for section headers.",
3668
+ tier: "flag",
3669
+ severity: 2,
3670
+ detect: (html) => {
3671
+ const root = parse(html);
3672
+ const seen = new Set();
3673
+ const hitsOut = [];
3674
+ for (const rows of repeatingRowSets(root, 2)) {
3675
+ const flagged = rows.filter((r) => !seen.has(r) && !elAllows(r, "row-kicker-eyebrow") && rowHasKickerAboveTitle(r));
3676
+ if (flagged.length < 2)
3677
+ continue;
3678
+ for (const r of flagged) {
3679
+ seen.add(r);
3680
+ hitsOut.push({
3681
+ ruleId: "row-kicker-eyebrow",
3682
+ detail: `"${(textRuns(r)[0] ?? "").slice(0, 24)}"`,
3683
+ });
3684
+ }
3685
+ }
3686
+ return hitsOut;
3687
+ },
3688
+ },
3689
+ {
3690
+ id: "multiline-row-meta",
3691
+ category: "layout",
3692
+ tell: "A review quote or description wrapping to two-plus lines inside a repeating row, snapping the list's vertical rhythm against its single-line neighbors.",
3693
+ prevention: "Meta inside a repeating row is one line. Truncate with text-overflow: ellipsis or push the quote to the detail screen; a wrapping pull-quote does not belong in a list cell.",
3694
+ tier: "flag",
3695
+ severity: 2,
3696
+ detect: (html) => {
3697
+ const root = parse(html);
3698
+ const seen = new Set();
3699
+ const hitsOut = [];
3700
+ for (const rows of repeatingRowSets(root, 2)) {
3701
+ for (const r of rows) {
3702
+ if (seen.has(r) || elAllows(r, "multiline-row-meta") || !rowHasMultilineMeta(r))
3703
+ continue;
3704
+ seen.add(r);
3705
+ hitsOut.push({ ruleId: "multiline-row-meta", detail: "wrapping quote/meta in a list row" });
3706
+ }
3707
+ }
3708
+ return hitsOut;
3709
+ },
3710
+ },
3711
+ {
3712
+ id: "overstuffed-row",
3713
+ category: "layout",
3714
+ tell: "The everything-row: thumbnail plus kicker plus title plus location plus quote plus mini-viz crammed into each repeated item, past any scannable density.",
3715
+ prevention: "Budget a repeating row at three info slots: subject or thumbnail, title, one decision metric. Everything else lives on the detail screen.",
3716
+ tier: "flag",
3717
+ severity: 2,
3718
+ detect: (html) => {
3719
+ const root = parse(html);
3720
+ const seen = new Set();
3721
+ const hitsOut = [];
3722
+ for (const rows of repeatingRowSets(root, 2)) {
3723
+ const over = rows.filter((r) => !seen.has(r) && !elAllows(r, "overstuffed-row") && rowInfoSlots(r) > OVERSTUFFED_SLOT_CAP);
3724
+ if (over.length < 2)
3725
+ continue;
3726
+ for (const r of over) {
3727
+ seen.add(r);
3728
+ hitsOut.push({ ruleId: "overstuffed-row", detail: `${rowInfoSlots(r)} slots in one row` });
3729
+ }
3730
+ }
3731
+ return hitsOut;
3732
+ },
3733
+ },
3734
+ {
3735
+ id: "row-as-card",
3736
+ category: "layout",
3737
+ tell: "Card-itis: a plain text feed where every uniform row gets its own border, radius, and elevation, floating on gaps instead of sitting in a list.",
3738
+ prevention: "Uniform text-led items (transactions, messages, check-ins) read as bare rows with 1px dividers. Spend per-item cards only on genuinely rich tiles, a photo with a price and rating, not on a repeated one-liner.",
3739
+ tier: "flag",
3740
+ severity: 1,
3741
+ detect: (html) => {
3742
+ const root = parse(html);
3743
+ const classMap = collectClassDecls(html);
3744
+ return findRowCards(root, classMap).map((set) => ({ ruleId: "row-as-card", detail: `${set.length} text-rows styled as cards` }));
3745
+ },
3746
+ },
3747
+ // ---- layout collapse + perf -------------------------------------------------
3748
+ {
3749
+ id: "reveal-specificity-trap",
3750
+ category: "layout",
3751
+ tell: "Scroll-reveal CSS where the hidden rule (`html.js .reveal`, 0-2-1) permanently outranks the revealed rule (`.reveal.in`, 0-2-0): the observer adds the class, the cascade ignores it, and everything below the hero stays invisible.",
3752
+ prevention: "Gate the revealed state exactly like the hidden one. If `html.js .reveal { opacity: 0 }` hides, then `html.js .reveal.in { opacity: 1 }` reveals; a bare `.reveal.in` loses the specificity contest and the section never appears.",
3753
+ tier: "fix",
3754
+ severity: 3,
3755
+ detect: (html) => findRevealSpecificityTraps(html).hits,
3756
+ fix: (html) => findRevealSpecificityTraps(html).fixedHtml,
3757
+ },
3758
+ {
3759
+ id: "will-change-misuse",
3760
+ category: "motion",
3761
+ tell: "will-change pointed at layout or paint properties (top, width, background, box-shadow) or at `all`: it buys a GPU layer that can never help those props, pure memory cost.",
3762
+ prevention: "will-change earns its layer only on compositable properties: transform, opacity, filter, clip-path. Never `will-change: all`, never layout/paint props, and only where first-frame stutter is actually observed.",
3763
+ tier: "fix",
3764
+ severity: 1,
3765
+ detect: (html) => Array.from({ length: countWillChangeMisuse(html) }, () => ({
3766
+ ruleId: "will-change-misuse",
3767
+ detail: "will-change on a non-compositable property",
3768
+ })),
3769
+ fix: (html) => fixWillChange(html),
3770
+ },
3771
+ // ---- fingerprint colors beyond indigo ---------------------------------------
3772
+ // dark-glow runs BEFORE purple-violet-wash so a violet glow is dropped as a
3773
+ // glow layer rather than having its color routed to the accent token.
3774
+ {
3775
+ id: "dark-glow",
3776
+ category: "visual",
3777
+ tell: "Neon under the furniture: wide, saturated box/text shadows glowing behind cards and buttons, the default 'premium dark SaaS' move.",
3778
+ prevention: "Shadows are for depth, not light. Never give an element a saturated colored glow (a wide-blur box-shadow/text-shadow or drop-shadow in a vivid color); on dark surfaces separate with surface tone and hairlines instead.",
3779
+ tier: "fix",
3780
+ severity: 2,
3781
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, (d) => countDarkGlow(d) > 0) }, () => ({ ruleId: "dark-glow", detail: "saturated wide-blur glow shadow" })),
3782
+ fix: (html) => rewriteCssGroups(html, (d) => countDarkGlow(d) > 0, stripDarkGlow),
3783
+ },
3784
+ {
3785
+ id: "purple-violet-wash",
3786
+ category: "color",
3787
+ tell: "Saturated purple/violet doing accent duty: the wider band behind the indigo hex list, and the single most recognized generated-UI color story.",
3788
+ prevention: "Treat the whole saturated violet band (hue ~252-296) as radioactive unless the brand genuinely owns it. When no accent was chosen, defer to the page's accent token instead of reaching for purple.",
3789
+ tier: "fix",
3790
+ severity: 1,
3791
+ sanctionedBy: (_styleRef, ctx) => ctx?.replicate === true,
3792
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasVioletWash) }, () => ({ ruleId: "purple-violet-wash", detail: "saturated violet-band color" })),
3793
+ fix: (html) => rewriteCssGroups(html, groupHasVioletWash, fixVioletGroup),
3794
+ },
3795
+ {
3796
+ id: "safe-green-default",
3797
+ category: "color",
3798
+ tell: "Tailwind emerald as the fallback personality: the accent models retreat to once purple is denied, the second-order version of the same non-choice.",
3799
+ prevention: "Emerald green is not a default. If the brand did not pick green, do not let 'not purple' resolve to #10b981; choose an accent that belongs to the subject.",
3800
+ tier: "flag",
3801
+ severity: 1,
3802
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasSafeGreen) }, () => ({ ruleId: "safe-green-default", detail: "Tailwind emerald accent hex" })),
3803
+ },
3804
+ {
3805
+ id: "cream-default-wash",
3806
+ category: "color",
3807
+ tell: "The cream-and-serif costume: a warm off-white ground plus serif display type, worn by default as 'tasteful' regardless of what the product is.",
3808
+ prevention: "Cream ground + serif display is an editorial voice, not a neutral default. Reach for it when the brief is editorial; otherwise pick a ground and voice that come from the subject.",
3809
+ tier: "flag",
3810
+ severity: 1,
3811
+ detect: (html) => {
3812
+ const bg = pageBackgroundHSL(html);
3813
+ const creamy = !!bg && bg.h >= 25 && bg.h <= 60 && bg.s >= 0.1 && bg.s <= 0.5 && bg.l >= 0.82;
3814
+ return creamy && pageHasSerifDisplay(html)
3815
+ ? [{ ruleId: "cream-default-wash", detail: "cream ground + serif display voice" }]
3816
+ : [];
3817
+ },
3818
+ },
3819
+ // ---- type hygiene -------------------------------------------------------------
3820
+ {
3821
+ id: "overused-font-stack",
3822
+ category: "type",
3823
+ tell: "The four display faces every AI-tell list names: Inter, Space Grotesk, Geist, Instrument Serif. Fine fonts, exhausted defaults.",
3824
+ prevention: "Do not default to Inter, Space Grotesk, Geist, or Instrument Serif for display type. Pick a face that argues for the subject; if a neutral grotesque is genuinely right, make it a choice, not a reflex.",
3825
+ tier: "flag",
3826
+ severity: 1,
3827
+ detect: (html) => findOverusedFonts(html).map((f) => ({ ruleId: "overused-font-stack", detail: f })),
3828
+ },
3829
+ {
3830
+ id: "single-font-page",
3831
+ category: "type",
3832
+ tell: "One family carrying the entire page: no pairing, no contrast of voice between display and body.",
3833
+ prevention: "Give display and body distinct voices: a deliberate pairing, or at minimum distinct optical weights/widths of one family used with intent. A page set entirely in one face at one register reads as unstyled.",
3834
+ tier: "flag",
3835
+ severity: 1,
3836
+ detect: (html) => {
3837
+ if (!isFullDocument(html))
3838
+ return [];
3839
+ const fams = declaredFamilies(html);
3840
+ return fams.length === 1
3841
+ ? [{ ruleId: "single-font-page", detail: `only "${fams[0]}" declared` }]
3842
+ : [];
3843
+ },
3844
+ },
3845
+ {
3846
+ id: "crushed-tracking",
3847
+ category: "type",
3848
+ tell: "Display tracking crushed past legibility (letter-spacing at -0.05em or tighter), the 'make it look designed' knob turned into a wall.",
3849
+ prevention: "Tighten display type with restraint: letter-spacing tighter than about -0.04em starts welding glyphs together. Large sizes rarely need more than -0.02em.",
3850
+ tier: "fix",
3851
+ severity: 1,
3852
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasCrushedTracking) }, () => ({ ruleId: "crushed-tracking", detail: "letter-spacing <= -0.05em" })),
3853
+ fix: (html) => rewriteCssGroups(html, groupHasCrushedTracking, fixCrushedTracking),
3854
+ },
3855
+ {
3856
+ id: "wide-body-tracking",
3857
+ category: "type",
3858
+ tell: "Body-size text tracked out to 0.08em+ without the uppercase that would justify it: airy-looking, exhausting to read.",
3859
+ prevention: "Wide letter-spacing belongs to short uppercase labels. Mixed-case body and UI text reads best at its natural tracking; past ~0.05em word shapes fall apart.",
3860
+ tier: "fix",
3861
+ severity: 1,
3862
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasWideBodyTracking) }, () => ({ ruleId: "wide-body-tracking", detail: "letter-spacing >= 0.08em on mixed-case text" })),
3863
+ fix: (html) => rewriteCssGroups(html, groupHasWideBodyTracking, fixWideBodyTracking),
3864
+ },
3865
+ {
3866
+ id: "tight-line-height",
3867
+ category: "type",
3868
+ tell: "Body-size text with line-height under 1.25: lines shingled on top of each other in the name of density.",
3869
+ prevention: "Body copy needs air: line-height 1.4-1.6 at text sizes (13-20px). Reserve tight leading for display sizes that set their own rules.",
3870
+ tier: "fix",
3871
+ severity: 1,
3872
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasTightLineHeight) }, () => ({ ruleId: "tight-line-height", detail: "line-height < 1.25 at body size" })),
3873
+ fix: (html) => rewriteCssGroups(html, groupHasTightLineHeight, fixTightLineHeight),
3874
+ },
3875
+ {
3876
+ id: "tiny-body-text",
3877
+ category: "type",
3878
+ tell: "Text under 11px pretending to be readable: below the floor of high-DPI legibility for anything that is not a tracked micro-label.",
3879
+ prevention: "12px is the floor for readable text. Under it, only short uppercase micro-labels survive, and only barely; body copy and UI labels never go there.",
3880
+ tier: "fix",
3881
+ severity: 1,
3882
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupHasTinyBodyText) }, () => ({ ruleId: "tiny-body-text", detail: "font-size < 11px on mixed-case text" })),
3883
+ fix: (html) => rewriteCssGroups(html, groupHasTinyBodyText, fixTinyBodyText),
3884
+ },
3885
+ {
3886
+ id: "monospace-body",
3887
+ category: "type",
3888
+ tell: "Prose set in a code font: body or paragraph rules pointing at monospace, cosplaying a terminal instead of setting readable text.",
3889
+ prevention: "Monospace is for code, data, and deliberate technical accents, never for body prose. Set paragraphs in a text face; quote code in <code>.",
3890
+ tier: "flag",
3891
+ severity: 1,
3892
+ detect: (html) => Array.from({ length: countMonospaceBody(html) }, () => ({ ruleId: "monospace-body", detail: "body/p rule with a monospace family" })),
3893
+ },
3894
+ // ---- blob + ghost + template structures ---------------------------------------
3895
+ {
3896
+ id: "over-rounded-card",
3897
+ category: "visual",
3898
+ tell: "Cards rounded into blobs: 40px+ radii on filled content surfaces, the 'friendly' dial turned past its stop.",
3899
+ prevention: "Content cards hold their shape: radius 8-24px. Reserve larger rounding for pills (which use full rounding deliberately) and standalone organic shapes, not for text-bearing surfaces.",
3900
+ tier: "fix",
3901
+ severity: 1,
3902
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupIsOverRounded) }, () => ({ ruleId: "over-rounded-card", detail: "border-radius >= 40px on a filled card" })),
3903
+ fix: (html) => rewriteCssGroups(html, groupIsOverRounded, fixOverRounded),
3904
+ },
3905
+ {
3906
+ id: "ghost-card",
3907
+ category: "visual",
3908
+ tell: "The ghost card: a hairline border AND a wide soft halo shadow on the same surface, hedging between two separation strategies.",
3909
+ prevention: "Pick the card's separation: a hairline border OR a soft shadow, not both. The doubled treatment reads as floaty and indecisive.",
3910
+ tier: "fix",
3911
+ severity: 1,
3912
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, groupIsGhostCard) }, () => ({ ruleId: "ghost-card", detail: "hairline border + wide soft shadow" })),
3913
+ fix: (html) => rewriteCssGroups(html, groupIsGhostCard, stripGhostShadow),
3914
+ },
3915
+ {
3916
+ id: "nested-cards",
3917
+ category: "layout",
3918
+ tell: "Cards inside cards: surfaced containers stacked so every level carries its own radius, fill, and edge, and depth stops meaning anything.",
3919
+ prevention: "One card level per region. Inside a card, group content with spacing and rules, not with another card; chips and badges are fine, surfaced sub-containers are not.",
3920
+ tier: "gate",
3921
+ severity: 1,
3922
+ detect: (html) => findNestedCards(parse(html), collectClassDecls(html)).map((el) => ({
3923
+ ruleId: "nested-cards",
3924
+ detail: `card container nested in a card ("${el.text.replace(/\s+/g, " ").trim().slice(0, 24)}")`,
3925
+ })),
3926
+ },
3927
+ {
3928
+ id: "numbered-section-markers",
3929
+ category: "layout",
3930
+ tell: "Decorative 01 / 02 / 03 scaffolding: leading-zero index labels stamped on sections whose order encodes nothing.",
3931
+ prevention: "Number sections only when order is the content (steps, a timeline, ranked results). Leading-zero markers as decoration are template scaffolding left showing.",
3932
+ tier: "flag",
3933
+ severity: 1,
3934
+ detect: (html) => findNumberedMarkers(parse(html)).map((el) => ({
3935
+ ruleId: "numbered-section-markers",
3936
+ detail: `"${el.text.replace(/\s+/g, " ").trim()}"`,
3937
+ })),
3938
+ },
3939
+ {
3940
+ id: "icon-topped-feature-card",
3941
+ category: "layout",
3942
+ tell: "The universal feature-card template: icon on top, heading, blurb, times three, the single most recycled section structure in generated landings.",
3943
+ prevention: "If a features section must exist, earn its structure: vary the geometry, lead with evidence (a screenshot, a number, a demo), or write one strong paragraph. Icon-heading-blurb x3 is the template every generator emits.",
3944
+ tier: "flag",
3945
+ severity: 1,
3946
+ detect: (html) => findIconToppedCardSets(parse(html)).map((set) => ({
3947
+ ruleId: "icon-topped-feature-card",
3948
+ detail: `${set.length} icon-topped cards in a row`,
3949
+ })),
3950
+ },
3951
+ // ---- motion tells ---------------------------------------------------------------
3952
+ {
3953
+ id: "bounce-easing",
3954
+ category: "motion",
3955
+ tell: "Entrance overshoot: cubic-bezier curves that spring past their target, making dialogs and cards wobble in like toys.",
3956
+ prevention: "Ease out, land once. No overshoot/elastic easing (cubic-bezier with y-values outside 0-1) on UI motion; interfaces settle, they do not bounce.",
3957
+ tier: "fix",
3958
+ severity: 1,
3959
+ detect: (html) => Array.from({ length: eachStyleAndInline(html, (d) => countBounceEasing(d) > 0) }, () => ({ ruleId: "bounce-easing", detail: "overshoot cubic-bezier" })),
3960
+ fix: (html) => rewriteCssGroups(html, (d) => countBounceEasing(d) > 0, fixBounceEasing),
3961
+ },
3962
+ {
3963
+ id: "layout-prop-animation",
3964
+ category: "motion",
3965
+ tell: "Transitions on layout properties (width/height/top/left/margin/padding): every frame reflows the page, and the motion stutters exactly where it wants to impress.",
3966
+ prevention: "Animate transforms and opacity, not layout. Move with translate, scale with transform, reveal with opacity/clip-path; for expanding panels prefer grid-template-rows or measured transforms over height transitions.",
3967
+ tier: "gate",
3968
+ severity: 1,
3969
+ detect: (html) => Array.from({ length: countLayoutPropTransitions(html) }, () => ({ ruleId: "layout-prop-animation", detail: "transition on a layout property" })),
3970
+ },
3971
+ {
3972
+ id: "hover-scale-image",
3973
+ category: "motion",
3974
+ tell: "The reflex zoom: transform scale on image/card hover, applied to everything because it is the one hover effect everyone has seen.",
3975
+ prevention: "Hover states should inform (affordance, elevation, focus), not inflate. If an image hover must move, keep it under 1.03 and pair it with a reason; better, respond with overlay or caption instead of scale.",
3976
+ tier: "flag",
3977
+ severity: 1,
3978
+ detect: (html) => Array.from({ length: countHoverScaleImage(html) }, () => ({ ruleId: "hover-scale-image", detail: "scale() on image hover" })),
3979
+ },
3980
+ // ---- UI microcopy ----------------------------------------------------------------
3981
+ {
3982
+ id: "benefit-speak",
3983
+ category: "copy",
3984
+ tell: "Marketing verbs that sell nothing specific: Elevate, Supercharge, Seamlessly, Empower, Unlock your... the vocabulary of copy written by no one about nothing.",
3985
+ prevention: "Say what the product does with concrete verbs and objects ('Search your meeting notes', not 'Unlock your knowledge'). Elevate/supercharge/streamline/empower/seamless/effortless/world-class are placeholders for a claim, not claims.",
3986
+ tier: "gate",
3987
+ severity: 1,
3988
+ detect: (html) => {
3989
+ const hits = [];
3990
+ for (const run of visibleTextRuns(html)) {
3991
+ for (const m of run.matchAll(BENEFIT_SPEAK_RE)) {
3992
+ hits.push({ ruleId: "benefit-speak", detail: `"${m[0]}"` });
3993
+ }
3994
+ }
3995
+ return hits;
3996
+ },
3997
+ },
3998
+ {
3999
+ id: "not-x-but-y-cadence",
4000
+ category: "copy",
4001
+ tell: "The manufactured-rebuttal cadence: \"It's not just X, it's Y\", contrast written as a tic rather than an argument.",
4002
+ prevention: "Make the claim directly. The 'not X, it's Y' construction is the most recognized generated-copy rhythm there is; if the contrast matters, show the difference with specifics.",
4003
+ tier: "flag",
4004
+ severity: 1,
4005
+ detect: (html) => visibleTextRuns(html)
4006
+ .filter((run) => NOT_X_BUT_Y_RE.test(run))
4007
+ .map((run) => ({
4008
+ ruleId: "not-x-but-y-cadence",
4009
+ detail: `"${run.slice(0, 48)}"`,
4010
+ })),
4011
+ },
4012
+ {
4013
+ id: "fabricated-precision",
4014
+ category: "copy",
4015
+ tell: "Numbers nobody measured: 99.9% uptime, 10x faster, #1 platform, trusted by thousands, precision invented to look like evidence.",
4016
+ prevention: "Use real numbers with real sources, or none. 99.9%/10x/#1/'trusted by thousands' without attribution are recognized filler stats; a screen is more credible with one true number than four invented ones.",
4017
+ tier: "flag",
4018
+ severity: 1,
4019
+ detect: (html) => {
4020
+ const hits = [];
4021
+ for (const run of visibleTextRuns(html)) {
4022
+ for (const m of run.matchAll(FABRICATED_PRECISION_RE)) {
4023
+ hits.push({ ruleId: "fabricated-precision", detail: `"${m[0]}"` });
4024
+ }
4025
+ }
4026
+ return hits;
4027
+ },
4028
+ },
4029
+ {
4030
+ id: "apologetic-error-copy",
4031
+ category: "copy",
4032
+ tell: "\"Oops! Something went wrong\": the error message that apologizes instead of helping, shipped verbatim by every template.",
4033
+ prevention: "Errors are guidance: name what failed and the next step ('Couldn't save. Check your connection and retry.'). Never 'Oops', 'Whoops', 'Uh oh', or a bare 'Something went wrong'.",
4034
+ tier: "gate",
4035
+ severity: 1,
4036
+ detect: (html) => {
4037
+ const hits = [];
4038
+ for (const run of visibleTextRuns(html)) {
4039
+ for (const m of run.matchAll(APOLOGETIC_ERROR_RE)) {
4040
+ hits.push({ ruleId: "apologetic-error-copy", detail: `"${m[0]}"` });
4041
+ }
4042
+ }
4043
+ return hits;
4044
+ },
4045
+ },
4046
+ ];