@monoes/monodesign 1.2.18 → 1.2.20

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.
@@ -0,0 +1,286 @@
1
+ import { parseRgb } from '../shared/color.mjs';
2
+ import {
3
+ checkBorders,
4
+ isEmojiOnlyText,
5
+ checkColors,
6
+ HEADING_TAGS,
7
+ checkIconTile,
8
+ checkItalicSerif,
9
+ checkHeroEyebrow,
10
+ checkRepeatedSectionKickers,
11
+ checkMotion,
12
+ checkGlow,
13
+ } from './checks-pure.mjs';
14
+ import {
15
+ readOwnBackgroundColor,
16
+ resolveBackground,
17
+ resolveGradientStops,
18
+ resolveBorderRadiusPx,
19
+ } from './checks-background.mjs';
20
+ import { resolveVarRefs, parseColorResolved } from './checks-css-values.mjs';
21
+ import { collectRepeatedSectionKickerCandidates } from './checks-dom-effects.mjs';
22
+ import {
23
+ resolveFontSizePx,
24
+ resolveLengthPx,
25
+ checkQuality,
26
+ } from './checks-quality.mjs';
27
+
28
+ function checkElementQualityDOM(el) {
29
+ const tag = el.tagName.toLowerCase();
30
+ const style = getComputedStyle(el);
31
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
32
+ const textLen = el.textContent?.trim().length || 0;
33
+ // Browser getComputedStyle resolves everything to px — direct parseFloat
34
+ // works.
35
+ const fontSize = parseFloat(style.fontSize) || 16;
36
+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
37
+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
38
+ const rect = el.getBoundingClientRect();
39
+ const lineMax = (typeof window !== 'undefined' && window.__MONODESIGN_CONFIG__?.lineLengthMax) || 80;
40
+ const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
41
+ return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null });
42
+ }
43
+
44
+ // Pure page-level skipped-heading walk. Takes a Document so it works in both
45
+ // the browser and jsdom.
46
+ function checkPageQualityFromDoc(doc) {
47
+ const findings = [];
48
+ const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
49
+ let prevLevel = 0;
50
+ let prevText = '';
51
+ for (const h of headings) {
52
+ const level = parseInt(h.tagName[1], 10);
53
+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
54
+ if (prevLevel > 0 && level > prevLevel + 1) {
55
+ findings.push({
56
+ id: 'skipped-heading',
57
+ snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
58
+ });
59
+ }
60
+ prevLevel = level;
61
+ prevText = text;
62
+ }
63
+ return findings;
64
+ }
65
+
66
+ // Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
67
+ function checkPageQualityDOM() {
68
+ return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
69
+ }
70
+
71
+ // Node adapters — take pre-extracted jsdom computed style
72
+
73
+ // jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
74
+ // CSS length the rule needs ourselves (walking the parent chain for
75
+ // font-size inheritance), and pass `rect: null` to skip the two rules that
76
+ // genuinely need element rects (line-length, cramped-padding).
77
+ function checkElementQuality(el, style, tag, window) {
78
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
79
+ const textLen = el.textContent?.trim().length || 0;
80
+ const fontSize = resolveFontSizePx(el, window);
81
+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
82
+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
83
+ return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });
84
+ }
85
+
86
+ function checkElementBorders(tag, style, overrides, resolvedRadius) {
87
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
88
+ const widths = {}, colors = {};
89
+ for (const s of sides) {
90
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
91
+ colors[s] = style[`border${s}Color`] || '';
92
+ // jsdom silently drops any border shorthand containing var(), leaving
93
+ // both width and color empty on the computed style. When the detectHtml
94
+ // pre-pass pulled a resolved value off the rule, use it to fill in the
95
+ // missing side so the side-tab check can run. Real browsers resolve
96
+ // var() natively, so this fallback is a no-op in the browser path.
97
+ if (widths[s] === 0 && overrides && overrides[s]) {
98
+ widths[s] = overrides[s].width;
99
+ colors[s] = overrides[s].color;
100
+ } else if (colors[s]?.startsWith('var(') && overrides?.[s]) {
101
+ // Longhand case: jsdom kept the width but left the color as the
102
+ // literal `var(...)` string. Substitute the resolved color.
103
+ colors[s] = overrides[s].color;
104
+ }
105
+ }
106
+ // resolvedRadius lets the caller pre-resolve the radius via
107
+ // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken
108
+ // shorthand serialization. Falls back to the computed value for tests
109
+ // and browser callers that don't pre-resolve.
110
+ const radius = resolvedRadius != null
111
+ ? resolvedRadius
112
+ : (parseFloat(style.borderRadius) || 0);
113
+ return checkBorders(tag, widths, colors, radius);
114
+ }
115
+
116
+ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
117
+ const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
118
+ const hasDirectText = directText.trim().length > 0;
119
+
120
+ const effectiveBg = resolveBackground(el, window, customPropMap);
121
+ // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
122
+ // parseRgb misses Tailwind-tokenized text colors. Resolve through the
123
+ // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
124
+ let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
125
+ if (!textColor) textColor = parseRgb(style.color);
126
+
127
+ // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
128
+ // blue }` at high specificity. The page's `a { color: inherit }` rule
129
+ // (Tailwind v4 preflight) loses to jsdom even though it WINS in real
130
+ // browsers (Chrome's UA wraps :link in :where() — zero specificity).
131
+ // When the page declares the inherit rule AND we see jsdom's default
132
+ // link blue on an anchor, walk to the nearest non-anchor ancestor and
133
+ // use its color instead.
134
+ if (
135
+ hasAnchorInheritRule &&
136
+ textColor &&
137
+ textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
138
+ (tag === 'a' || el.closest?.('a'))
139
+ ) {
140
+ let cur = el.parentElement;
141
+ while (cur && cur.tagName !== 'HTML') {
142
+ if (cur.tagName !== 'A') {
143
+ const ps = window.getComputedStyle(cur);
144
+ const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
145
+ if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
146
+ textColor = inh;
147
+ break;
148
+ }
149
+ }
150
+ cur = cur.parentElement;
151
+ }
152
+ }
153
+
154
+ return checkColors({
155
+ tag,
156
+ textColor,
157
+ bgColor: readOwnBackgroundColor(el, style),
158
+ effectiveBg,
159
+ effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
160
+ fontSize: parseFloat(style.fontSize) || 16,
161
+ fontWeight: parseInt(style.fontWeight, 10) || 400,
162
+ hasDirectText,
163
+ isEmojiOnly: isEmojiOnlyText(directText),
164
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
165
+ bgImage: style.backgroundImage || '',
166
+ classList: el.getAttribute?.('class') || el.className || '',
167
+ });
168
+ }
169
+
170
+ function checkElementIconTile(el, tag, window) {
171
+ if (!HEADING_TAGS.has(tag)) return [];
172
+ const sibling = el.previousElementSibling;
173
+ if (!sibling) return [];
174
+
175
+ const sibStyle = window.getComputedStyle(sibling);
176
+ // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
177
+ const sibWidth = parseFloat(sibStyle.width) || 0;
178
+ const sibHeight = parseFloat(sibStyle.height) || 0;
179
+
180
+ const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
181
+ let iconWidth = 0;
182
+ if (iconChild) {
183
+ const iconStyle = window.getComputedStyle(iconChild);
184
+ iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
185
+ }
186
+ // Or: tile contains an emoji/symbol character directly as its only content
187
+ const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
188
+ const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
189
+
190
+ return checkIconTile({
191
+ headingTag: tag,
192
+ headingText: el.textContent || '',
193
+ headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
194
+ siblingTag: sibling.tagName.toLowerCase(),
195
+ siblingWidth: sibWidth,
196
+ siblingHeight: sibHeight,
197
+ siblingBottom: 0,
198
+ siblingBgColor: parseRgb(sibStyle.backgroundColor),
199
+ siblingBgImage: sibStyle.backgroundImage || '',
200
+ siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
201
+ siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),
202
+ hasIconChild: !!iconChild || hasInlineEmojiIcon,
203
+ iconChildWidth: iconWidth,
204
+ });
205
+ }
206
+
207
+ function checkElementItalicSerif(el, style, tag) {
208
+ if (tag !== 'h1' && tag !== 'h2') return [];
209
+ return checkItalicSerif({
210
+ tag,
211
+ fontStyle: style.fontStyle || '',
212
+ fontFamily: style.fontFamily || '',
213
+ fontSize: parseFloat(style.fontSize) || 0,
214
+ headingText: el.textContent || '',
215
+ });
216
+ }
217
+
218
+ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
219
+ if (tag !== 'h1') return [];
220
+ const sibling = el.previousElementSibling;
221
+ if (!sibling) return [];
222
+ const sibStyle = window.getComputedStyle(sibling);
223
+ // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
224
+ // etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
225
+ // without resolution every style-based gate fails silently on Tailwind v4 builds.
226
+ const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
227
+ const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
228
+ const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
229
+ const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
230
+ const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
231
+ const siblingFontSize = parseFloat(fontSizeRaw) || 0;
232
+ // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
233
+ // gate falls through cleanly. jsdom returns letter-spacing verbatim
234
+ // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
235
+ return checkHeroEyebrow({
236
+ headingTag: tag,
237
+ headingText: el.textContent || '',
238
+ headingFontSize: parseFloat(headingFontSizeRaw) || 0,
239
+ siblingTag: sibling.tagName.toLowerCase(),
240
+ siblingText: sibling.textContent || '',
241
+ siblingTextTransform: sibStyle.textTransform || '',
242
+ siblingFontSize,
243
+ siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
244
+ siblingFontWeight: fontWeightRaw || '',
245
+ siblingColor: colorRaw || '',
246
+ });
247
+ }
248
+
249
+ function checkRepeatedSectionKickersFromDoc(doc, win) {
250
+ const candidates = collectRepeatedSectionKickerCandidates(
251
+ doc,
252
+ (el) => win.getComputedStyle(el),
253
+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
254
+ );
255
+ return checkRepeatedSectionKickers({ candidates });
256
+ }
257
+
258
+ function checkElementMotion(tag, style) {
259
+ return checkMotion({
260
+ tag,
261
+ transitionProperty: style.transitionProperty || '',
262
+ animationName: style.animationName || '',
263
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
264
+ classList: '',
265
+ });
266
+ }
267
+
268
+ function checkElementGlow(tag, style, effectiveBg) {
269
+ if (!style.boxShadow || style.boxShadow === 'none') return [];
270
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
271
+ }
272
+
273
+ export {
274
+ checkElementQualityDOM,
275
+ checkPageQualityFromDoc,
276
+ checkPageQualityDOM,
277
+ checkElementQuality,
278
+ checkElementBorders,
279
+ checkElementColors,
280
+ checkElementIconTile,
281
+ checkElementItalicSerif,
282
+ checkElementHeroEyebrow,
283
+ checkRepeatedSectionKickersFromDoc,
284
+ checkElementMotion,
285
+ checkElementGlow,
286
+ };
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Regex-on-HTML checks shared between browser and Node page-level detection.
3
+ * These don't need DOM access, just the raw HTML string.
4
+ */
5
+ function checkHtmlPatterns(html) {
6
+ const findings = [];
7
+
8
+ // --- Color ---
9
+
10
+ // AI color palette: purple/violet
11
+ const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
12
+ if (purpleHexRe.test(html)) {
13
+ const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
14
+ if (purpleTextRe.test(html)) {
15
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
16
+ }
17
+ }
18
+
19
+ // Gradient text (background-clip: text + gradient)
20
+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
21
+ let gm;
22
+ while ((gm = gradientRe.exec(html)) !== null) {
23
+ const start = Math.max(0, gm.index - 200);
24
+ const context = html.substring(start, gm.index + gm[0].length + 200);
25
+ if (/gradient/i.test(context)) {
26
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
27
+ break;
28
+ }
29
+ }
30
+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
31
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
32
+ }
33
+
34
+ // --- Layout ---
35
+
36
+ // Monotonous spacing
37
+ const spacingValues = [];
38
+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
39
+ let sm;
40
+ while ((sm = spacingRe.exec(html)) !== null) {
41
+ const v = parseInt(sm[1], 10);
42
+ if (v > 0 && v < 200) spacingValues.push(v);
43
+ }
44
+ const gapRe = /gap\s*:\s*(\d+)px/gi;
45
+ while ((sm = gapRe.exec(html)) !== null) {
46
+ spacingValues.push(parseInt(sm[1], 10));
47
+ }
48
+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
49
+ while ((sm = twSpaceRe.exec(html)) !== null) {
50
+ spacingValues.push(parseInt(sm[1], 10) * 4);
51
+ }
52
+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
53
+ while ((sm = remSpacingRe.exec(html)) !== null) {
54
+ const v = Math.round(parseFloat(sm[1]) * 16);
55
+ if (v > 0 && v < 200) spacingValues.push(v);
56
+ }
57
+ const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
58
+ if (roundedSpacing.length >= 10) {
59
+ const counts = {};
60
+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
61
+ const maxCount = Math.max(...Object.values(counts));
62
+ const dominantPct = maxCount / roundedSpacing.length;
63
+ const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
64
+ if (dominantPct > 0.6 && unique.length <= 3) {
65
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
66
+ findings.push({
67
+ id: 'monotonous-spacing',
68
+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
69
+ });
70
+ }
71
+ }
72
+
73
+ // --- Motion ---
74
+
75
+ // Bounce/elastic animation names
76
+ const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
77
+ const bounceMatch = bounceRe.exec(html);
78
+ if (bounceMatch) {
79
+ const animationToken = bounceMatch[1]
80
+ .split(/[,\s]+/)
81
+ .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
82
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
83
+ }
84
+
85
+ // Overshoot cubic-bezier
86
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
87
+ let bm;
88
+ while ((bm = bezierRe.exec(html)) !== null) {
89
+ const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
90
+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
91
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
92
+ break;
93
+ }
94
+ }
95
+
96
+ // Layout property transitions
97
+ const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
98
+ let tm;
99
+ while ((tm = transRe.exec(html)) !== null) {
100
+ const val = tm[1].toLowerCase();
101
+ if (/\ball\b/.test(val)) continue;
102
+ const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
103
+ if (found) {
104
+ findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
105
+ break;
106
+ }
107
+ }
108
+
109
+ // --- Dark glow ---
110
+
111
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
112
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
113
+ if (darkBgRe.test(html) || twDarkBg.test(html)) {
114
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
115
+ let shm;
116
+ while ((shm = shadowRe.exec(html)) !== null) {
117
+ const val = shm[1];
118
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
119
+ if (!colorMatch) continue;
120
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
121
+ if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
122
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
123
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
124
+ findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
125
+ break;
126
+ }
127
+ }
128
+ }
129
+
130
+ // --- Provider tells (gated): repeating-gradient stripes (GPT) ---
131
+ if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
132
+ findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
133
+ }
134
+
135
+ // --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
136
+ // The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
137
+ // transparent 1px)` layers (one per axis) tiled by a repeating
138
+ // `background-size` cell. Both signals must co-occur in the SAME style block
139
+ // (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
140
+ // tiling background-size is a fixed crosshair, not a grid, and a single
141
+ // hairline is a legitimate ruled line. Scoping to one block also stops
142
+ // unrelated single-axis rules on separate elements from adding up across the
143
+ // page. Count hairlines only inside `background`/`background-image` values so
144
+ // a hairline in an unrelated property (mask-image, border-image) can't stand
145
+ // in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
146
+ // nested parens, so match the hairline stop directly rather than parsing
147
+ // whole gradient layers.
148
+ {
149
+ const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
150
+ const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
151
+ const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
152
+ const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
153
+ let blk;
154
+ while ((blk = blockRe.exec(html)) !== null) {
155
+ const block = blk[1] || blk[2] || blk[3] || '';
156
+ if (!gridSizeRe.test(block)) continue;
157
+ let hairlineCount = 0;
158
+ let bm;
159
+ bgDeclRe.lastIndex = 0;
160
+ while ((bm = bgDeclRe.exec(block)) !== null) {
161
+ const stops = bm[1].match(hairlineRe);
162
+ if (stops) hairlineCount += stops.length;
163
+ }
164
+ if (hairlineCount >= 2) {
165
+ findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
166
+ break;
167
+ }
168
+ }
169
+ }
170
+
171
+ // --- Provider tells (gated): "X theater" framing copy (GPT) ---
172
+ // Lives here (regex-on-HTML) rather than in the text-content analyzers so it
173
+ // runs in the bundled browser path too, not just the CLI/static path.
174
+ {
175
+ const bodyText = html
176
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
177
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
178
+ .replace(/<[^>]+>/g, ' ');
179
+ const tm = /\b(\w+)\s+theater\b/i.exec(bodyText);
180
+ if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
181
+ }
182
+
183
+ // --- Provider tells (gated): image hover transform (Gemini) ---
184
+ // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
185
+ // hover:rotate / hover:translate utility on an <img>. Each distinct
186
+ // mechanism is its own finding.
187
+ const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
188
+ if (imgHoverCss.test(html)) {
189
+ findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
190
+ }
191
+ const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
192
+ let im;
193
+ while ((im = imgTagRe.exec(html)) !== null) {
194
+ if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) {
195
+ findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' });
196
+ }
197
+ }
198
+
199
+ return findings;
200
+ }
201
+
202
+ export {
203
+ checkHtmlPatterns,
204
+ };