@etus/seven-skill 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/skills/seven/SKILL.md +162 -0
  2. package/.claude/skills/seven/reference/audit.md +120 -0
  3. package/.claude/skills/seven/reference/brand.md +24 -0
  4. package/.claude/skills/seven/reference/clarify.md +76 -0
  5. package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
  6. package/.claude/skills/seven/reference/motion-design.md +71 -0
  7. package/.claude/skills/seven/reference/polish.md +55 -0
  8. package/.claude/skills/seven/reference/product.md +45 -0
  9. package/.claude/skills/seven/reference/shape.md +85 -0
  10. package/.claude/skills/seven/reference/spatial-design.md +60 -0
  11. package/.claude/skills/seven/reference/typography.md +43 -0
  12. package/.claude/skills/seven/reference/ux-writing.md +47 -0
  13. package/.claude/skills/seven/scripts/load-context.mjs +84 -0
  14. package/.claude-plugin/marketplace.json +34 -0
  15. package/.claude-plugin/plugin.json +12 -0
  16. package/LICENSE +190 -0
  17. package/NOTICE.md +26 -0
  18. package/README.md +118 -0
  19. package/cli/bin/commands/skills.mjs +664 -0
  20. package/cli/bin/seven.mjs +68 -0
  21. package/cli/engine/browser/injected/index.mjs +84 -0
  22. package/cli/engine/cli/main.mjs +215 -0
  23. package/cli/engine/detect-antipatterns-browser.js +3014 -0
  24. package/cli/engine/detect-antipatterns.mjs +44 -0
  25. package/cli/engine/engines/browser/detect-url.mjs +108 -0
  26. package/cli/engine/engines/regex/detect-text.mjs +508 -0
  27. package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
  28. package/cli/engine/engines/static-html/detect-html.mjs +211 -0
  29. package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
  30. package/cli/engine/findings.mjs +28 -0
  31. package/cli/engine/node/file-system.mjs +212 -0
  32. package/cli/engine/profile/profiler.mjs +169 -0
  33. package/cli/engine/registry/seven-antipatterns.mjs +494 -0
  34. package/cli/engine/rules/checks.mjs +1518 -0
  35. package/cli/engine/shared/color.mjs +204 -0
  36. package/cli/engine/shared/constants.mjs +91 -0
  37. package/cli/engine/shared/page.mjs +9 -0
  38. package/cli/engine/shared/tokens.mjs +153 -0
  39. package/cli/engine/token-data.generated.mjs +691 -0
  40. package/docs/detector-rules.md +605 -0
  41. package/docs/figma-token-rule-exploration.md +185 -0
  42. package/package.json +76 -0
@@ -0,0 +1,1518 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import {
6
+ PT_PT_VOCAB_PAIRS,
7
+ SAFE_TAGS,
8
+ SYSTEM_FONT_FALLBACK_TOKENS,
9
+ } from '../shared/constants.mjs'
10
+ import {
11
+ colorToHex,
12
+ contrastRatio,
13
+ getHue,
14
+ hasChroma,
15
+ isFuchsia,
16
+ isNeutralColor,
17
+ parseRgb,
18
+ relativeLuminance,
19
+ } from '../shared/color.mjs'
20
+ import {
21
+ colorToCssVar,
22
+ componentRadiiPx,
23
+ isAllowedFontFamily,
24
+ minFontSizePx,
25
+ pillRadiiPx,
26
+ sanctionedEasingBeziers,
27
+ suggestSemanticToken,
28
+ surfaceRadiiPx,
29
+ } from '../shared/tokens.mjs'
30
+
31
+ // Detection runs in two worlds: Node (regex + static-html) and a real browser.
32
+ // `window` only exists in the browser; the *-DOM adapters key off this flag.
33
+ const DETECTOR_IS_BROWSER = typeof window !== 'undefined'
34
+
35
+ // ─── Section 1: Seven text checks (regex-on-source) ─────────────────────────
36
+ // These eight checks scan raw file text and need no computed-style cascade.
37
+ // Every engine runs them: the regex engine over source text, the static-html
38
+ // and browser engines over the markup + stylesheet text they extract.
39
+
40
+ function lineOf(text, index) {
41
+ let line = 1
42
+ for (let i = 0; i < index && i < text.length; i++) {
43
+ if (text.charCodeAt(i) === 10) line++
44
+ }
45
+ return line
46
+ }
47
+
48
+ function snippetAt(text, index, padding = 40) {
49
+ const start = Math.max(0, index - padding)
50
+ const end = Math.min(text.length, index + padding + 20)
51
+ return text.slice(start, end).replace(/\s+/g, ' ').trim()
52
+ }
53
+
54
+ function parseFontFamilyValue(raw) {
55
+ return raw
56
+ .split(',')
57
+ .map((s) => s.trim().toLowerCase().replace(/^['"]/, '').replace(/['"]$/, ''))
58
+ }
59
+
60
+ /**
61
+ * Classify a CSS variable name as 'color', 'length', or 'unknown' from Seven's
62
+ * naming conventions. Length wins when both signals appear (e.g.
63
+ * --button-border-width is a length even though `border` is a color signal).
64
+ */
65
+ function classifyVarName(name) {
66
+ const hasColorSignal =
67
+ /(?:^|-)(?:color|fg|bg|primary|secondary|accent|background|foreground|muted|destructive|warning|success|info|brand|surface|ring|border|stroke|fill|outline)(?:$|-)/i.test(name)
68
+ const hasLengthSignal =
69
+ /(?:^|-)(?:width|height|radius|padding|margin|gap|inset|offset|space|font-size|line-height|leading|tracking|letter-spacing|fs|size|weight|duration|delay)(?:$|-)/i.test(name)
70
+ if (hasLengthSignal) return 'length'
71
+ if (hasColorSignal) return 'color'
72
+ if (/-text$/i.test(name)) return 'length'
73
+ return 'unknown'
74
+ }
75
+
76
+ /** A value that is exactly `var(--something)` with optional whitespace. */
77
+ function isSingleVarReference(value) {
78
+ return /^\s*var\(\s*--[a-zA-Z0-9-]+\s*\)\s*$/.test(value)
79
+ }
80
+
81
+ function isWithinSvgOrChartTag(text, index) {
82
+ const before = text.slice(Math.max(0, index - 800), index).toLowerCase()
83
+ const lastSvgOpen = before.lastIndexOf('<svg')
84
+ const lastSvgClose = before.lastIndexOf('</svg>')
85
+ if (lastSvgOpen > lastSvgClose) return true
86
+ return /data-role\s*=\s*['"]chart['"]/i.test(before)
87
+ }
88
+
89
+ const FONT_FAMILY_RE = /font-family\s*:\s*([^;{}\n]+)/gi
90
+
91
+ export function checkNonInterFont(text) {
92
+ const findings = []
93
+ let match
94
+ while ((match = FONT_FAMILY_RE.exec(text)) !== null) {
95
+ const parts = parseFontFamilyValue(match[1])
96
+ const primary = parts[0] ?? ''
97
+ if (!primary || primary.startsWith('var(') || primary.startsWith('inherit')) continue
98
+ // Token-grounded: the allowed families come from the @etus/tokens
99
+ // fontFamily tokens (plus JetBrains Mono and the system fallbacks).
100
+ if (isAllowedFontFamily(primary) || SYSTEM_FONT_FALLBACK_TOKENS.has(primary)) {
101
+ continue
102
+ }
103
+ findings.push({ id: 'non-inter-font', line: lineOf(text, match.index), snippet: snippetAt(text, match.index) })
104
+ }
105
+ return findings
106
+ }
107
+
108
+ const HEX_COLOR_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
109
+ const RGB_COLOR_RE = /\brgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*[,/)]/g
110
+ const HSL_COLOR_RE = /\bhsla?\s*\(\s*\d+(?:deg)?\s*,?\s*\d+%/g
111
+ const OKLCH_LITERAL_RE = /\boklch\s*\(\s*[\d.]+/g
112
+
113
+ /**
114
+ * Extract the full color value starting at `index` — the regexes above match a
115
+ * prefix for rgb/hsl/oklch, so the closing paren has to be read back to get a
116
+ * value the token lookup can resolve.
117
+ */
118
+ function fullColorAt(text, index) {
119
+ const rest = text.slice(index, index + 80)
120
+ if (rest[0] === '#') return (rest.match(/^#[0-9a-fA-F]{3,8}/) || [''])[0]
121
+ const fn = rest.match(/^(?:rgba?|hsla?|oklch)\s*\([^)]*\)/i)
122
+ return fn ? fn[0] : ''
123
+ }
124
+
125
+ export function checkHardcodedColorNotToken(text) {
126
+ const findings = []
127
+ const seen = new Set()
128
+ const add = (match) => {
129
+ const key = `${match.index}:${match[0]}`
130
+ if (seen.has(key)) return
131
+ seen.add(key)
132
+ // Token-grounded: when the literal is a Seven token value, name the token
133
+ // so the fix is precise instead of a generic "use a variable".
134
+ const cssVar = colorToCssVar(fullColorAt(text, match.index))
135
+ const base = snippetAt(text, match.index)
136
+ findings.push({
137
+ id: 'hardcoded-color-not-token',
138
+ line: lineOf(text, match.index),
139
+ snippet: cssVar ? `${base} (use var(${cssVar}))` : base,
140
+ })
141
+ }
142
+ let m
143
+ while ((m = HEX_COLOR_RE.exec(text)) !== null) add(m)
144
+ while ((m = RGB_COLOR_RE.exec(text)) !== null) add(m)
145
+ while ((m = HSL_COLOR_RE.exec(text)) !== null) add(m)
146
+ while ((m = OKLCH_LITERAL_RE.exec(text)) !== null) add(m)
147
+ return findings
148
+ }
149
+
150
+ // Tailwind default palette colors. A `bg-green-500` / `text-neutral-900` /
151
+ // `border-red-200` utility paints a color that bypasses @etus/tokens exactly
152
+ // as a hex literal does — the token scale never sees it.
153
+ const TW_PALETTE_HUES =
154
+ 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
155
+ const TW_COLOR_UTILITIES =
156
+ 'bg|text|border|ring|ring-offset|from|to|via|fill|stroke|outline|decoration|divide|placeholder|caret|accent|shadow'
157
+ const RAW_TW_PALETTE_RE = new RegExp(
158
+ `\\b(?:${TW_COLOR_UTILITIES})-(${TW_PALETTE_HUES})-\\d{2,3}\\b`,
159
+ 'g'
160
+ )
161
+
162
+ // Each Tailwind hue family maps onto a Seven semantic role. The actual token
163
+ // name for that role is then resolved from the @etus/tokens snapshot, so the
164
+ // finding points at the real Seven equivalent — not a generic "use a token".
165
+ const TW_HUE_TO_ROLE = {
166
+ red: 'destructive', rose: 'destructive',
167
+ green: 'success', emerald: 'success', lime: 'success', teal: 'success',
168
+ amber: 'warning', yellow: 'warning', orange: 'warning',
169
+ blue: 'info', sky: 'info', cyan: 'info', indigo: 'info',
170
+ violet: 'accent', purple: 'accent', fuchsia: 'accent', pink: 'accent',
171
+ slate: 'muted', gray: 'muted', zinc: 'muted', neutral: 'muted', stone: 'muted',
172
+ }
173
+
174
+ export function checkRawTailwindPaletteColor(text) {
175
+ const findings = []
176
+ let match
177
+ RAW_TW_PALETTE_RE.lastIndex = 0
178
+ while ((match = RAW_TW_PALETTE_RE.exec(text)) !== null) {
179
+ const role = TW_HUE_TO_ROLE[match[1]]
180
+ const token = role ? suggestSemanticToken(role) : null
181
+ const suggestion = token
182
+ ? `use the Seven ${role} token: bg-[color:var(${token})]`
183
+ : 'use a Seven color token: bg-[color:var(--...)]'
184
+ findings.push({
185
+ id: 'raw-tailwind-palette-color',
186
+ line: lineOf(text, match.index),
187
+ snippet: `${match[0]}: Tailwind palette color bypasses @etus/tokens; ${suggestion}`,
188
+ })
189
+ }
190
+ return findings
191
+ }
192
+
193
+ const COLOR_PROP_RE = /(?:bg|text|border|ring)-\[(?:([a-z]+):)?([^\]]+)\]/g
194
+
195
+ export function checkMissingColorTypeHint(text) {
196
+ const findings = []
197
+ let match
198
+ while ((match = COLOR_PROP_RE.exec(text)) !== null) {
199
+ const hint = match[1]
200
+ const value = match[2] ?? ''
201
+ if (hint) continue
202
+ if (!isSingleVarReference(value)) continue
203
+ const varName = value.match(/var\(\s*(--[a-zA-Z0-9-]+)/)?.[1] ?? ''
204
+ if (classifyVarName(varName) !== 'color') continue
205
+ findings.push({
206
+ id: 'missing-color-type-hint',
207
+ line: lineOf(text, match.index),
208
+ snippet: snippetAt(text, match.index),
209
+ })
210
+ }
211
+ return findings
212
+ }
213
+
214
+ const LENGTH_PROP_RE = /\btext-\[(?:([a-z]+):)?([^\]]+)\]/g
215
+
216
+ export function checkMissingLengthTypeHint(text) {
217
+ const findings = []
218
+ let match
219
+ while ((match = LENGTH_PROP_RE.exec(text)) !== null) {
220
+ const hint = match[1]
221
+ const value = match[2] ?? ''
222
+ if (hint) continue
223
+ if (!isSingleVarReference(value)) continue
224
+ const varName = value.match(/var\(\s*(--[a-zA-Z0-9-]+)/)?.[1] ?? ''
225
+ if (classifyVarName(varName) !== 'length') continue
226
+ findings.push({
227
+ id: 'missing-length-type-hint',
228
+ line: lineOf(text, match.index),
229
+ snippet: snippetAt(text, match.index),
230
+ })
231
+ }
232
+ return findings
233
+ }
234
+
235
+ const CLASS_ATTR_RE = /\b(?:className|class)\s*=\s*["'`]([^"'`]+)["'`]/g
236
+
237
+ export function checkLeadingNoneWithLengthVar(text) {
238
+ const findings = []
239
+ let match
240
+ while ((match = CLASS_ATTR_RE.exec(text)) !== null) {
241
+ const classes = match[1] ?? ''
242
+ if (!/\bleading-none\b/.test(classes)) continue
243
+ if (!/text-\[length:var\(/.test(classes)) continue
244
+ findings.push({
245
+ id: 'leading-none-with-length-var',
246
+ line: lineOf(text, match.index),
247
+ snippet: snippetAt(text, match.index),
248
+ })
249
+ }
250
+ return findings
251
+ }
252
+
253
+ const FUCHSIA_OKLCH_RE = /oklch\(\s*[0-9.]+%?\s+[0-9.]+\s+(2[89][0-9]|3[0-3][0-9])(?:\.[0-9]+)?\s*[/)]/gi
254
+ const FUCHSIA_HEX_RE = /#(?:d9[0-9a-f]{1,2}[0-9a-f]ef|d946ef|e879f9|c026d3|a21caf|86198f)\b/gi
255
+
256
+ export function checkFuchsiaInUiChrome(text) {
257
+ const findings = []
258
+ let match
259
+ while ((match = FUCHSIA_HEX_RE.exec(text)) !== null) {
260
+ if (isWithinSvgOrChartTag(text, match.index)) continue
261
+ findings.push({ id: 'fuchsia-in-ui-chrome', line: lineOf(text, match.index), snippet: snippetAt(text, match.index) })
262
+ }
263
+ while ((match = FUCHSIA_OKLCH_RE.exec(text)) !== null) {
264
+ if (isWithinSvgOrChartTag(text, match.index)) continue
265
+ findings.push({ id: 'fuchsia-in-ui-chrome', line: lineOf(text, match.index), snippet: snippetAt(text, match.index) })
266
+ }
267
+ return findings
268
+ }
269
+
270
+ export function checkPtPtVocab(text) {
271
+ const findings = []
272
+ for (const pair of PT_PT_VOCAB_PAIRS) {
273
+ const re = new RegExp(`\\b${pair.ptPt}\\b`, 'giu')
274
+ let match
275
+ while ((match = re.exec(text)) !== null) {
276
+ findings.push({
277
+ id: 'pt-pt-vocab',
278
+ line: lineOf(text, match.index),
279
+ snippet: `${snippetAt(text, match.index)} (use "${pair.ptBr}")`,
280
+ })
281
+ }
282
+ }
283
+ return findings
284
+ }
285
+
286
+ const PURE_BLACK_WHITE_RE =
287
+ /(?:#(?:0{3,4}|0{6}|0{8})\b|#(?:f{3,4}|f{6}|f{8})\b|\brgba?\s*\(\s*0\s*,\s*0\s*,\s*0\s*[,/)]|\brgba?\s*\(\s*255\s*,\s*255\s*,\s*255\s*[,/)])/gi
288
+
289
+ export function checkPureBlackOrWhite(text) {
290
+ const findings = []
291
+ let match
292
+ while ((match = PURE_BLACK_WHITE_RE.exec(text)) !== null) {
293
+ // Token-grounded: pure black/white are primitives; name the base token.
294
+ const cssVar = colorToCssVar(fullColorAt(text, match.index))
295
+ const base = snippetAt(text, match.index)
296
+ findings.push({
297
+ id: 'pure-black-or-white',
298
+ line: lineOf(text, match.index),
299
+ snippet: cssVar ? `${base} (use var(${cssVar}))` : base,
300
+ })
301
+ }
302
+ return findings
303
+ }
304
+
305
+ // Emoji characters render as multicolor glyphs regardless of CSS `color` and
306
+ // have no place in Seven chrome copy. The class covers flags, pictographs,
307
+ // symbols, dingbats, variation selectors, ZWJ sequences, and skin-tone
308
+ // modifiers — the same range the upstream detector used to exclude emoji-only
309
+ // text nodes from contrast checks.
310
+ const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u
311
+ const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu
312
+
313
+ export function isEmojiOnlyText(text) {
314
+ if (!text) return false
315
+ if (!EMOJI_CHAR_RE.test(text)) return false
316
+ return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''
317
+ }
318
+
319
+ // Chrome elements whose text content must not carry emoji glyphs.
320
+ const CHROME_TEXT_TAG_RE =
321
+ /<(button|h[1-6]|label|a)\b[^>]*>([\s\S]*?)<\/\1>/gi
322
+
323
+ export function checkEmojiInChrome(text) {
324
+ const findings = []
325
+ let match
326
+ CHROME_TEXT_TAG_RE.lastIndex = 0
327
+ while ((match = CHROME_TEXT_TAG_RE.exec(text)) !== null) {
328
+ // Strip nested tags so we only test the rendered text run.
329
+ const inner = match[2].replace(/<[^>]+>/g, '')
330
+ if (!EMOJI_CHAR_RE.test(inner)) continue
331
+ findings.push({
332
+ id: 'emoji-in-chrome',
333
+ line: lineOf(text, match.index),
334
+ snippet: snippetAt(text, match.index),
335
+ })
336
+ }
337
+ return findings
338
+ }
339
+
340
+ // Em-dash (U+2014) and en-dash (U+2013) in user-facing copy. JSX/HTML text
341
+ // runs and string literals carry copy; the long dash there reads as
342
+ // machine-generated prose.
343
+ const LONG_DASH_RE = /[–—]/g
344
+
345
+ export function checkForbiddenEmDash(text) {
346
+ // Mask JS/CSS block and line comments before scanning. JSDoc and `//`
347
+ // explanatory comments are developer prose, not user-facing copy, and they
348
+ // routinely use the long dash as rhetorical punctuation. Newlines are kept
349
+ // so line offsets stay accurate for the surviving matches. The `[^:]` guard
350
+ // on `//` keeps `https://` from being treated as a comment start.
351
+ const masked = text
352
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
353
+ .replace(/(^|[^:])\/\/[^\n]*/g, (m, lead) => lead + m.slice(lead.length).replace(/./g, ' '))
354
+ const findings = []
355
+ let match
356
+ LONG_DASH_RE.lastIndex = 0
357
+ while ((match = LONG_DASH_RE.exec(masked)) !== null) {
358
+ findings.push({
359
+ id: 'forbidden-em-dash',
360
+ line: lineOf(text, match.index),
361
+ snippet: snippetAt(text, match.index),
362
+ })
363
+ }
364
+ return findings
365
+ }
366
+
367
+ /**
368
+ * The Seven text-check set. Every engine iterates this map: the regex engine
369
+ * runs each check against source text; the static-html and browser engines
370
+ * run them against the markup and stylesheet text extracted from the
371
+ * parsed/rendered document. These checks are deliberately text-based.
372
+ */
373
+ export const CHECKS = {
374
+ 'non-inter-font': checkNonInterFont,
375
+ 'hardcoded-color-not-token': checkHardcodedColorNotToken,
376
+ 'raw-tailwind-palette-color': checkRawTailwindPaletteColor,
377
+ 'missing-color-type-hint': checkMissingColorTypeHint,
378
+ 'missing-length-type-hint': checkMissingLengthTypeHint,
379
+ 'leading-none-with-length-var': checkLeadingNoneWithLengthVar,
380
+ 'fuchsia-in-ui-chrome': checkFuchsiaInUiChrome,
381
+ 'pt-pt-vocab': checkPtPtVocab,
382
+ 'pure-black-or-white': checkPureBlackOrWhite,
383
+ 'emoji-in-chrome': checkEmojiInChrome,
384
+ 'forbidden-em-dash': checkForbiddenEmDash,
385
+ }
386
+
387
+ /** Run every text check against a text blob, returning raw `{ id, line, snippet }`. */
388
+ export function runChecks(text, ruleIds = null) {
389
+ const findings = []
390
+ for (const [id, check] of Object.entries(CHECKS)) {
391
+ if (ruleIds && !ruleIds.includes(id)) continue
392
+ findings.push(...check(text))
393
+ }
394
+ return findings
395
+ }
396
+
397
+ // ─── Section 2: pure element checks (computed-style inputs) ──────────────────
398
+ // These run on extracted style props and DOM-only inputs, so they work in both
399
+ // the static-html cascade and a real browser. They take numbers and resolved
400
+ // colors, never raw DOM nodes — the adapters in Section 4/5 do the extraction.
401
+
402
+ const BORDER_SAFE_TAGS = new Set([
403
+ 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'title', 'br',
404
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
405
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'hr', 'input', 'textarea', 'select',
406
+ ])
407
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
408
+
409
+ // Seven's two radius stacks, token-grounded from @etus/tokens. Component scale
410
+ // is for buttons/inputs/badges; surface scale is for cards/dialogs/popovers.
411
+ // Mixing them on one element is the `radius-mixed-scales` drift.
412
+ const COMPONENT_RADII = new Set(componentRadiiPx())
413
+ const SURFACE_RADII = new Set(surfaceRadiiPx())
414
+ // The pill/full-radius floor: any radius at or above this is a pill.
415
+ const PILL_RADIUS_MIN = pillRadiiPx()[0] ?? 9999
416
+
417
+ /**
418
+ * Side-stripe border check. A border-left or border-right thicker than 1px,
419
+ * carrying a non-neutral (accent) color, is the banned Seven side stripe.
420
+ * Top/bottom accents are not the side-stripe pattern and are left alone.
421
+ */
422
+ function checkBorders(tag, widths, colors) {
423
+ if (BORDER_SAFE_TAGS.has(tag)) return []
424
+ const findings = []
425
+ for (const side of ['Left', 'Right']) {
426
+ const w = widths[side]
427
+ if (w <= 1 || isNeutralColor(colors[side])) continue
428
+ const otherSides = ['Top', 'Right', 'Bottom', 'Left'].filter((s) => s !== side)
429
+ const maxOther = Math.max(...otherSides.map((s) => widths[s]))
430
+ // Only a stripe if this side stands out: the others are hairline-or-absent
431
+ // or this side is at least twice as thick.
432
+ if (!(maxOther <= 1 || w >= maxOther * 2)) continue
433
+ findings.push({
434
+ id: 'side-stripe-border',
435
+ snippet: `border-${side.toLowerCase()}: ${w}px accent stripe`,
436
+ })
437
+ }
438
+ return findings
439
+ }
440
+
441
+ /**
442
+ * Radius-mixed-scales check. A single element resolving border-radius corners
443
+ * that fall in both the component scale and the surface scale is drift.
444
+ */
445
+ function checkRadiusMixedScales(tag, cornerRadii) {
446
+ if (BORDER_SAFE_TAGS.has(tag)) return []
447
+ const rounded = cornerRadii.map((r) => Math.round(r)).filter((r) => r > 0)
448
+ const hasComponent = rounded.some((r) => COMPONENT_RADII.has(r))
449
+ const hasSurface = rounded.some((r) => SURFACE_RADII.has(r))
450
+ if (hasComponent && hasSurface) {
451
+ return [{
452
+ id: 'radius-mixed-scales',
453
+ snippet: `mixed radius scales: ${[...new Set(rounded)].join('px / ')}px on one element`,
454
+ }]
455
+ }
456
+ return []
457
+ }
458
+
459
+ const BUTTON_TAGS = new Set(['button'])
460
+
461
+ /** Pill radius on a button. Buttons stay on the component radius scale. */
462
+ function checkPillOnButton(tag, role, radiusPx, classList) {
463
+ const isButton = BUTTON_TAGS.has(tag) || role === 'button'
464
+ if (!isButton) return []
465
+ const classStr = typeof classList === 'string' ? classList : ''
466
+ if (radiusPx >= PILL_RADIUS_MIN || /\brounded-full\b/.test(classStr)) {
467
+ return [{ id: 'pill-on-button', snippet: `pill radius on <${tag}>` }]
468
+ }
469
+ return []
470
+ }
471
+
472
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'option'])
473
+
474
+ /**
475
+ * Tactile-press check. Every Seven affordance gets the system-wide micro-press
476
+ * — a transform transition that the hover/active states drive. A clickable
477
+ * element whose transition-property covers neither `transform` nor `all` has
478
+ * opted out.
479
+ */
480
+ function checkTactilePress(tag, role, transitionProperty, classList, hasClickHandler) {
481
+ const isClickable =
482
+ tag === 'button' ||
483
+ (tag === 'a' && role === 'button') ||
484
+ INTERACTIVE_ROLES.has(role) ||
485
+ hasClickHandler
486
+ if (!isClickable) return []
487
+ const props = (transitionProperty || '')
488
+ .split(',')
489
+ .map((p) => p.trim().toLowerCase())
490
+ .filter(Boolean)
491
+ const classStr = typeof classList === 'string' ? classList : ''
492
+ const hasTransformTransition =
493
+ props.includes('transform') ||
494
+ props.includes('all') ||
495
+ /\btransition(?:-transform)?\b/.test(classStr)
496
+ if (hasTransformTransition) return []
497
+ return [{ id: 'missing-tactile-press', snippet: `clickable <${tag}> with no tactile-press transition` }]
498
+ }
499
+
500
+ /**
501
+ * Non-Lucide icon check. Lucide icons use a 24x24 viewBox, 2px stroke, round
502
+ * caps and joins, and no fill. An inline <svg> that visibly deviates from that
503
+ * contract is a non-Lucide icon.
504
+ */
505
+ function checkLucideIcon(viewBox, strokeWidth, strokeLinecap, fill) {
506
+ // No identifying attributes at all — likely a decorative shape, not an icon.
507
+ if (!viewBox && !strokeWidth) return []
508
+ const vb = (viewBox || '').trim()
509
+ const isLucideViewBox = vb === '' || vb === '0 0 24 24'
510
+ const sw = strokeWidth != null && strokeWidth !== '' ? parseFloat(strokeWidth) : null
511
+ const isLucideStroke = sw == null || sw === 2
512
+ const cap = (strokeLinecap || '').toLowerCase()
513
+ const isLucideCap = cap === '' || cap === 'round'
514
+ const f = (fill || '').toLowerCase()
515
+ const isLucideFill = f === '' || f === 'none'
516
+ if (isLucideViewBox && isLucideStroke && isLucideCap && isLucideFill) return []
517
+ const reasons = []
518
+ if (!isLucideViewBox) reasons.push(`viewBox ${vb}`)
519
+ if (!isLucideStroke) reasons.push(`stroke-width ${strokeWidth}`)
520
+ if (!isLucideCap) reasons.push(`stroke-linecap ${cap}`)
521
+ if (!isLucideFill) reasons.push(`fill ${f}`)
522
+ return [{ id: 'non-lucide-icon', snippet: `non-Lucide <svg> (${reasons.join(', ')})` }]
523
+ }
524
+
525
+ /**
526
+ * Color checks: low-contrast, gray-on-color, gradient-text-decorative, plus a
527
+ * pure-black background flag and fuchsia chroma on chrome. Background-dependent
528
+ * checks run against a solid bg or, for gradients, every gradient stop.
529
+ */
530
+ function checkColors(opts) {
531
+ const {
532
+ tag, textColor, bgColor, effectiveBg, fontSize, fontWeight,
533
+ hasDirectText, isEmojiOnly, bgClip, bgImage, classList,
534
+ } = opts
535
+ if (SAFE_TAGS.has(tag)) {
536
+ const isStyledButton =
537
+ (tag === 'a' || tag === 'button') && hasDirectText && bgColor && bgColor.a > 0.5
538
+ if (!isStyledButton) return []
539
+ }
540
+ const findings = []
541
+
542
+ // Pure black background (solid or near-solid only).
543
+ if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
544
+ findings.push({ id: 'pure-black-or-white', snippet: '#000000 background' })
545
+ }
546
+
547
+ if (hasDirectText && textColor && !isEmojiOnly && effectiveBg) {
548
+ // Gray text on a colored background.
549
+ const textLum = relativeLuminance(textColor)
550
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85
551
+ if (isGray && hasChroma(effectiveBg, 40)) {
552
+ findings.push({
553
+ id: 'gray-on-color',
554
+ snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}`,
555
+ })
556
+ }
557
+
558
+ // Low contrast (WCAG 2.1 AA).
559
+ const ratio = contrastRatio(textColor, effectiveBg)
560
+ const isLargeText = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700)
561
+ const threshold = isLargeText ? 3.0 : 4.5
562
+ if (ratio < threshold) {
563
+ findings.push({
564
+ id: 'low-contrast',
565
+ snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}`,
566
+ })
567
+ }
568
+
569
+ // Fuchsia chroma on UI chrome text.
570
+ if (isFuchsia(textColor) && tag !== 'svg') {
571
+ findings.push({ id: 'fuchsia-in-ui-chrome', snippet: `fuchsia text ${colorToHex(textColor)} on chrome` })
572
+ }
573
+ }
574
+
575
+ // Decorative gradient text.
576
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
577
+ findings.push({ id: 'gradient-text-decorative', snippet: 'background-clip: text + gradient' })
578
+ }
579
+
580
+ // Tailwind class checks.
581
+ if (classList) {
582
+ const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ')
583
+ if (/\bbg-black\b(?!\/)/.test(classStr) || /\bbg-white\b(?!\/)/.test(classStr)) {
584
+ findings.push({ id: 'pure-black-or-white', snippet: /\bbg-white\b/.test(classStr) ? 'bg-white' : 'bg-black' })
585
+ }
586
+ const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/)
587
+ const colorBgMatch = classStr.match(
588
+ /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/
589
+ )
590
+ if (grayMatch && colorBgMatch) {
591
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` })
592
+ }
593
+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
594
+ findings.push({ id: 'gradient-text-decorative', snippet: 'bg-clip-text + bg-gradient (Tailwind)' })
595
+ }
596
+ if (/\bfuchsia-\d/.test(classStr) && /\b(?:bg|text|border|ring)-fuchsia-\d/.test(classStr)) {
597
+ findings.push({ id: 'fuchsia-in-ui-chrome', snippet: 'fuchsia utility on chrome' })
598
+ }
599
+ }
600
+
601
+ return findings
602
+ }
603
+
604
+ function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
605
+ if (!hasShadow && !hasBorder) return false
606
+ return hasRadius || hasBg
607
+ }
608
+
609
+ const LAYOUT_TRANSITION_PROPS = new Set([
610
+ 'width', 'height', 'padding', 'margin',
611
+ 'max-height', 'max-width', 'min-height', 'min-width',
612
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
613
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
614
+ ])
615
+
616
+ /**
617
+ * True when a cubic-bezier matches a sanctioned Seven easing token. The
618
+ * catalog declares --ease-bounce and --ease-spring, so an overshooting curve
619
+ * that IS one of those token values is on-system, not drift.
620
+ */
621
+ function isSanctionedBezier(a, b, c, d) {
622
+ return sanctionedEasingBeziers().some(
623
+ ([x1, y1, x2, y2]) =>
624
+ Math.abs(x1 - a) < 0.01 &&
625
+ Math.abs(y1 - b) < 0.01 &&
626
+ Math.abs(x2 - c) < 0.01 &&
627
+ Math.abs(y2 - d) < 0.01
628
+ )
629
+ }
630
+
631
+ /** Motion checks: bounce/elastic easing and layout-property transitions. */
632
+ function checkMotion(opts) {
633
+ const { tag, transitionProperty, animationName, timingFunctions, classList } = opts
634
+ if (SAFE_TAGS.has(tag)) return []
635
+ const findings = []
636
+
637
+ if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
638
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` })
639
+ }
640
+ if (classList && /\banimate-bounce\b/.test(classList)) {
641
+ findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' })
642
+ }
643
+ if (timingFunctions) {
644
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g
645
+ let m
646
+ while ((m = bezierRe.exec(timingFunctions)) !== null) {
647
+ const x1 = parseFloat(m[1])
648
+ const y1 = parseFloat(m[2])
649
+ const x2 = parseFloat(m[3])
650
+ const y2 = parseFloat(m[4])
651
+ const overshoots = y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1
652
+ if (overshoots && !isSanctionedBezier(x1, y1, x2, y2)) {
653
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` })
654
+ break
655
+ }
656
+ }
657
+ }
658
+
659
+ if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
660
+ const props = transitionProperty.split(',').map((p) => p.trim().toLowerCase())
661
+ const layoutFound = props.filter((p) => LAYOUT_TRANSITION_PROPS.has(p))
662
+ if (layoutFound.length > 0) {
663
+ findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` })
664
+ }
665
+ }
666
+
667
+ return findings
668
+ }
669
+
670
+ /**
671
+ * Glow check. A colored, blurred box-shadow on a dark surface is the AI "dark
672
+ * glow" look; in Seven, shadows are reserved for overlays and cards carry
673
+ * hierarchy with borders — so the finding is `shadows-not-borders`.
674
+ */
675
+ function checkGlow(opts) {
676
+ const { boxShadow, effectiveBg } = opts
677
+ if (!boxShadow || boxShadow === 'none') return []
678
+ if (!effectiveBg) return []
679
+ const bgLum = relativeLuminance(effectiveBg)
680
+ if (bgLum >= 0.1) return []
681
+
682
+ const parts = boxShadow.split(/,(?![^(]*\))/)
683
+ for (const shadow of parts) {
684
+ const colorMatch = shadow.match(/rgba?\([^)]+\)/)
685
+ if (!colorMatch) continue
686
+ const color = parseRgb(colorMatch[0])
687
+ if (!color || !hasChroma(color, 30)) continue
688
+ const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length)
689
+ const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]))
690
+ const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
691
+ .map((m) => parseFloat(m[1]))
692
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
693
+ return [{ id: 'shadows-not-borders', snippet: `colored glow (${colorToHex(color)}) on dark surface` }]
694
+ }
695
+ }
696
+ return []
697
+ }
698
+
699
+ const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption'])
700
+
701
+ /**
702
+ * Quality checks on text-bearing elements: line-length, cramped-padding,
703
+ * body-text-viewport-edge, tight-leading, justified-text, tiny-text,
704
+ * all-caps-body, wide-tracking. Two checks (line-length, cramped-padding,
705
+ * body-text-viewport-edge) need element rects; pass `rect: null` to skip them.
706
+ */
707
+ function checkQuality(opts) {
708
+ const {
709
+ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx,
710
+ rect, lineMax = 80, viewportWidth = 0,
711
+ } = opts
712
+ const findings = []
713
+
714
+ if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
715
+ const charsPerLine = rect.width / (fontSize * 0.5)
716
+ if (charsPerLine > lineMax + 5) {
717
+ findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` })
718
+ }
719
+ }
720
+
721
+ if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
722
+ const borders = {
723
+ top: parseFloat(style.borderTopWidth) || 0,
724
+ right: parseFloat(style.borderRightWidth) || 0,
725
+ bottom: parseFloat(style.borderBottomWidth) || 0,
726
+ left: parseFloat(style.borderLeftWidth) || 0,
727
+ }
728
+ const borderCount = Object.values(borders).filter((w) => w > 0).length
729
+ const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'
730
+ if (borderCount >= 2 || hasBg) {
731
+ const vPads = []
732
+ const hPads = []
733
+ if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0)
734
+ if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0)
735
+ if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0)
736
+ if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0)
737
+ const vMin = vPads.length ? Math.min(...vPads) : Infinity
738
+ const hMin = hPads.length ? Math.min(...hPads) : Infinity
739
+ const vThresh = Math.max(4, fontSize * 0.3)
740
+ const hThresh = Math.max(8, fontSize * 0.5)
741
+ if (vMin < vThresh) {
742
+ findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need >=${vThresh.toFixed(1)}px for ${fontSize}px text)` })
743
+ } else if (hMin < hThresh) {
744
+ findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need >=${hThresh.toFixed(1)}px for ${fontSize}px text)` })
745
+ }
746
+ }
747
+ }
748
+
749
+ if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
750
+ const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'))
751
+ const hasOwnBg =
752
+ style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'
753
+ const isPositioned = ['fixed', 'absolute'].includes(style.position || '')
754
+ const widthRatio = rect.width / viewportWidth
755
+ const leftClose = rect.left < 16
756
+ const rightClose = rect.right > viewportWidth - 16
757
+ if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
758
+ const which =
759
+ leftClose && rightClose
760
+ ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
761
+ : leftClose
762
+ ? `left ${Math.round(rect.left)}px`
763
+ : `right ${Math.round(viewportWidth - rect.right)}px`
764
+ findings.push({
765
+ id: 'body-text-viewport-edge',
766
+ snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})`,
767
+ })
768
+ }
769
+ }
770
+
771
+ if (hasDirectText && textLen > 50 && !HEADING_TAGS.has(tag)) {
772
+ if (lineHeightPx != null && fontSize > 0) {
773
+ const ratio = lineHeightPx / fontSize
774
+ if (ratio > 0 && ratio < 1.3) {
775
+ findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` })
776
+ }
777
+ }
778
+ }
779
+
780
+ if (hasDirectText && style.textAlign === 'justify') {
781
+ const hyphens = style.hyphens || style.webkitHyphens || ''
782
+ if (hyphens !== 'auto') {
783
+ findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' })
784
+ }
785
+ }
786
+
787
+ // Token-grounded: the floor is the smallest @etus/tokens font-size token.
788
+ if (hasDirectText && textLen > 20 && fontSize < minFontSizePx()) {
789
+ const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']
790
+ const inUIContext =
791
+ el.closest &&
792
+ el.closest(
793
+ 'button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'
794
+ )
795
+ const isUppercase = style.textTransform === 'uppercase'
796
+ if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
797
+ findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` })
798
+ }
799
+ }
800
+
801
+ if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase' && !HEADING_TAGS.has(tag)) {
802
+ findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` })
803
+ }
804
+
805
+ if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
806
+ if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
807
+ const trackingEm = letterSpacingPx / fontSize
808
+ if (trackingEm > 0.05) {
809
+ findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` })
810
+ }
811
+ }
812
+ }
813
+
814
+ return findings
815
+ }
816
+
817
+ // ─── Section 3: regex-on-HTML page checks ───────────────────────────────────
818
+
819
+ /**
820
+ * Page-level checks that read the raw HTML string with no DOM access. Shared
821
+ * by the static-html and browser engines.
822
+ */
823
+ export function checkHtmlPatterns(html) {
824
+ const findings = []
825
+
826
+ // Pure black background.
827
+ if (/background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi.test(html)) {
828
+ findings.push({ id: 'pure-black-or-white', snippet: 'Pure #000 background' })
829
+ }
830
+
831
+ // Decorative gradient text.
832
+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi
833
+ let gm
834
+ while ((gm = gradientRe.exec(html)) !== null) {
835
+ const start = Math.max(0, gm.index - 200)
836
+ const context = html.substring(start, gm.index + gm[0].length + 200)
837
+ if (/gradient/i.test(context)) {
838
+ findings.push({ id: 'gradient-text-decorative', snippet: 'background-clip: text + gradient' })
839
+ break
840
+ }
841
+ }
842
+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
843
+ findings.push({ id: 'gradient-text-decorative', snippet: 'bg-clip-text + bg-gradient (Tailwind)' })
844
+ }
845
+
846
+ // Monotonous spacing.
847
+ const spacingValues = []
848
+ let sm
849
+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi
850
+ while ((sm = spacingRe.exec(html)) !== null) {
851
+ const v = parseInt(sm[1], 10)
852
+ if (v > 0 && v < 200) spacingValues.push(v)
853
+ }
854
+ const gapRe = /gap\s*:\s*(\d+)px/gi
855
+ while ((sm = gapRe.exec(html)) !== null) spacingValues.push(parseInt(sm[1], 10))
856
+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g
857
+ while ((sm = twSpaceRe.exec(html)) !== null) spacingValues.push(parseInt(sm[1], 10) * 4)
858
+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi
859
+ while ((sm = remSpacingRe.exec(html)) !== null) {
860
+ const v = Math.round(parseFloat(sm[1]) * 16)
861
+ if (v > 0 && v < 200) spacingValues.push(v)
862
+ }
863
+ const roundedSpacing = spacingValues.map((v) => Math.round(v / 4) * 4)
864
+ if (roundedSpacing.length >= 10) {
865
+ const counts = {}
866
+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1
867
+ const maxCount = Math.max(...Object.values(counts))
868
+ const dominantPct = maxCount / roundedSpacing.length
869
+ const unique = [...new Set(roundedSpacing)].filter((v) => v > 0)
870
+ if (dominantPct > 0.6 && unique.length <= 3) {
871
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]
872
+ findings.push({
873
+ id: 'monotonous-spacing',
874
+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
875
+ })
876
+ }
877
+ }
878
+
879
+ // Bounce / elastic animation names.
880
+ if (/animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi.test(html)) {
881
+ findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' })
882
+ }
883
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g
884
+ let bm
885
+ while ((bm = bezierRe.exec(html)) !== null) {
886
+ const x1 = parseFloat(bm[1])
887
+ const y1 = parseFloat(bm[2])
888
+ const x2 = parseFloat(bm[3])
889
+ const y2 = parseFloat(bm[4])
890
+ const overshoots = y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1
891
+ if (overshoots && !isSanctionedBezier(x1, y1, x2, y2)) {
892
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` })
893
+ break
894
+ }
895
+ }
896
+
897
+ // Layout-property transitions.
898
+ const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi
899
+ let tm
900
+ while ((tm = transRe.exec(html)) !== null) {
901
+ const val = tm[1].toLowerCase()
902
+ if (/\ball\b/.test(val)) continue
903
+ const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi)
904
+ if (found) {
905
+ findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` })
906
+ break
907
+ }
908
+ }
909
+
910
+ // Glow on a dark surface.
911
+ 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
912
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/
913
+ if (darkBgRe.test(html) || twDarkBg.test(html)) {
914
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi
915
+ let shm
916
+ while ((shm = shadowRe.exec(html)) !== null) {
917
+ const val = shm[1]
918
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
919
+ if (!colorMatch) continue
920
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]
921
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue
922
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map((p) => +(p[1] || p[2]))
923
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
924
+ findings.push({ id: 'shadows-not-borders', snippet: `colored glow (rgb(${r},${g},${b})) on dark page` })
925
+ break
926
+ }
927
+ }
928
+ }
929
+
930
+ return findings
931
+ }
932
+
933
+ // ─── Section 4: background resolution ───────────────────────────────────────
934
+
935
+ function readOwnBackgroundColor(el, computedStyle) {
936
+ const bg = parseRgb(computedStyle.backgroundColor)
937
+ if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg
938
+ const rawStyle = el.getAttribute?.('style') || ''
939
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i)
940
+ const inlineBg = bgMatch ? bgMatch[1].trim() : ''
941
+ if (!inlineBg) return bg
942
+ if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg
943
+ const fromRgb = parseRgb(inlineBg)
944
+ if (fromRgb) return fromRgb
945
+ const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i)
946
+ if (hexMatch) {
947
+ const h = hexMatch[1]
948
+ if (h.length === 6) {
949
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }
950
+ }
951
+ return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }
952
+ }
953
+ return bg
954
+ }
955
+
956
+ /**
957
+ * Walk the ancestor chain for the first opaque background color. Body/html
958
+ * gradients are treated as decorative and resolve to white; on other elements
959
+ * a gradient with no underlying solid color bails to null.
960
+ */
961
+ export function resolveBackground(el, win) {
962
+ let current = el
963
+ while (current && current.nodeType === 1) {
964
+ const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current)
965
+ const bgImage = style.backgroundImage || ''
966
+ const hasGradientOrUrl =
967
+ bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))
968
+ const bg = parseRgb(style.backgroundColor)
969
+ if (bg && bg.a > 0.1) {
970
+ if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg
971
+ }
972
+ if (hasGradientOrUrl) {
973
+ if (current.tagName === 'BODY' || current.tagName === 'HTML') {
974
+ return { r: 255, g: 255, b: 255, a: 1 }
975
+ }
976
+ return null
977
+ }
978
+ current = current.parentElement
979
+ }
980
+ return { r: 255, g: 255, b: 255, a: 1 }
981
+ }
982
+
983
+ // Parse a single CSS length token to pixels.
984
+ function parseRadiusToPx(value, widthPx) {
985
+ if (!value || typeof value !== 'string') return null
986
+ const trimmed = value.trim()
987
+ if (!trimmed) return null
988
+ const first = trimmed.split(/\s+/)[0]
989
+ const num = parseFloat(first)
990
+ if (Number.isNaN(num)) return null
991
+ if (/%$/.test(first)) {
992
+ if (widthPx && widthPx > 0) return (num / 100) * widthPx
993
+ return num
994
+ }
995
+ return num
996
+ }
997
+
998
+ export function resolveBorderRadiusPx(el, style, widthPx) {
999
+ const fromComputed = parseRadiusToPx(style.borderRadius, widthPx)
1000
+ if (fromComputed !== null) return fromComputed
1001
+ return 0
1002
+ }
1003
+
1004
+ // Resolve var(--X[, fallback]) refs in a value string against a custom-prop
1005
+ // map. Recurses up to 8 levels for chained refs. Returns the original string
1006
+ // when no refs are present or the chain does not resolve. The static CSS
1007
+ // cascade depends on this to resolve Tailwind v4 token wrappers.
1008
+ export function resolveVarRefs(raw, customPropMap, depth = 0) {
1009
+ if (typeof raw !== 'string' || !raw.includes('var(')) return raw
1010
+ if (depth > 8) return raw
1011
+ return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (full, name, fallback) => {
1012
+ const v = customPropMap && (customPropMap.get ? customPropMap.get(name) : customPropMap[name])
1013
+ if (v != null) return resolveVarRefs(v, customPropMap, depth + 1)
1014
+ return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : full
1015
+ })
1016
+ }
1017
+
1018
+ // OKLCH -> sRGB conversion (Bjorn Ottosson's matrices). L in 0..1 (or %),
1019
+ // C typically 0..~0.4, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
1020
+ // Needed because the static cascade cannot compute oklch() — and Seven's
1021
+ // token ramp is OKLCH throughout, so without this the palette is invisible
1022
+ // to the contrast and color checks.
1023
+ export function oklchToRgb(L, C, H) {
1024
+ const hRad = (H * Math.PI) / 180
1025
+ const a = C * Math.cos(hRad)
1026
+ const b = C * Math.sin(hRad)
1027
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b
1028
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b
1029
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b
1030
+ const lc = l_ * l_ * l_
1031
+ const mc = m_ * m_ * m_
1032
+ const sc = s_ * s_ * s_
1033
+ const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc
1034
+ const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc
1035
+ const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc
1036
+ const enc = (x) => {
1037
+ const c = Math.max(0, Math.min(1, x))
1038
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
1039
+ }
1040
+ return {
1041
+ r: Math.round(enc(rLin) * 255),
1042
+ g: Math.round(enc(gLin) * 255),
1043
+ b: Math.round(enc(bLin) * 255),
1044
+ a: 1,
1045
+ }
1046
+ }
1047
+
1048
+ // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match. Used
1049
+ // by the static cascade where a value might be any CSS color form.
1050
+ export function parseAnyColor(s) {
1051
+ if (!s || typeof s !== 'string') return null
1052
+ const str = s.trim()
1053
+ if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null
1054
+ let m
1055
+ m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/)
1056
+ if (m) {
1057
+ return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 }
1058
+ }
1059
+ m = str.match(/^#([0-9a-f]{3,8})$/i)
1060
+ if (m) {
1061
+ const h = m[1]
1062
+ if (h.length === 3 || h.length === 4) {
1063
+ return {
1064
+ r: parseInt(h[0] + h[0], 16),
1065
+ g: parseInt(h[1] + h[1], 16),
1066
+ b: parseInt(h[2] + h[2], 16),
1067
+ a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
1068
+ }
1069
+ }
1070
+ if (h.length === 6 || h.length === 8) {
1071
+ return {
1072
+ r: parseInt(h.slice(0, 2), 16),
1073
+ g: parseInt(h.slice(2, 4), 16),
1074
+ b: parseInt(h.slice(4, 6), 16),
1075
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
1076
+ }
1077
+ }
1078
+ }
1079
+ // OKLCH — the CSS minifier may drop the space after `%`, so the L/C
1080
+ // separator can be absent. Match L (optional %), then C and H permissively.
1081
+ m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i)
1082
+ if (m) {
1083
+ const Lnum = parseFloat(m[1])
1084
+ const L = m[2] === '%' ? Lnum / 100 : Lnum
1085
+ return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]))
1086
+ }
1087
+ return null
1088
+ }
1089
+
1090
+ // Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1091
+ // font-size context. Returns null for "normal" / unparseable values.
1092
+ export function resolveLengthPx(value, fontSizePx) {
1093
+ if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null
1094
+ const num = parseFloat(value)
1095
+ if (Number.isNaN(num)) return null
1096
+ if (value.endsWith('px')) return num
1097
+ if (value.endsWith('rem')) return num * 16
1098
+ if (value.endsWith('em')) return num * fontSizePx
1099
+ if (value.endsWith('%')) return (num / 100) * fontSizePx
1100
+ return num * fontSizePx
1101
+ }
1102
+
1103
+ // Resolve a CSS font-size value to pixels by walking up the parent chain.
1104
+ function resolveFontSizePx(el, win) {
1105
+ const chain = []
1106
+ let cur = el
1107
+ while (cur && cur.nodeType === 1) {
1108
+ const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize
1109
+ chain.push(fs || '')
1110
+ cur = cur.parentElement
1111
+ }
1112
+ let px = 16
1113
+ for (let i = chain.length - 1; i >= 0; i--) {
1114
+ const v = chain[i]
1115
+ if (!v || v === 'inherit') continue
1116
+ const num = parseFloat(v)
1117
+ if (Number.isNaN(num)) continue
1118
+ if (v.endsWith('px')) px = num
1119
+ else if (v.endsWith('rem')) px = num * 16
1120
+ else if (v.endsWith('em')) px = num * px
1121
+ else if (v.endsWith('%')) px = (num / 100) * px
1122
+ else px = num
1123
+ }
1124
+ return px
1125
+ }
1126
+
1127
+ // Collect every corner radius a style resolves. A real browser exposes each
1128
+ // corner as a longhand; the static cascade stores `border-radius` verbatim,
1129
+ // which may be a multi-value shorthand (`8px 8px 24px 24px`). Both forms are
1130
+ // handled so the mixed-scales check sees all four corners.
1131
+ function collectCornerRadii(style, widthPx) {
1132
+ const corners = [
1133
+ style.borderTopLeftRadius,
1134
+ style.borderTopRightRadius,
1135
+ style.borderBottomLeftRadius,
1136
+ style.borderBottomRightRadius,
1137
+ ].filter((v) => v != null && v !== '')
1138
+ if (corners.length > 0) {
1139
+ return corners.map((v) => parseRadiusToPx(v, widthPx) || 0)
1140
+ }
1141
+ // Shorthand string: split on `/` (horizontal / vertical radii) then on
1142
+ // whitespace, parsing each token independently.
1143
+ const raw = typeof style.borderRadius === 'string' ? style.borderRadius.trim() : ''
1144
+ if (!raw) return [0]
1145
+ const tokens = raw
1146
+ .split('/')
1147
+ .join(' ')
1148
+ .split(/\s+/)
1149
+ .filter(Boolean)
1150
+ if (tokens.length <= 1) {
1151
+ return [parseRadiusToPx(raw, widthPx) || 0]
1152
+ }
1153
+ return tokens.map((t) => parseRadiusToPx(t, widthPx) || 0)
1154
+ }
1155
+
1156
+ // ─── Section 5: browser element adapters (live DOM) ─────────────────────────
1157
+
1158
+ export function checkElementBordersDOM(el) {
1159
+ const tag = el.tagName.toLowerCase()
1160
+ if (BORDER_SAFE_TAGS.has(tag)) return []
1161
+ const rect = el.getBoundingClientRect()
1162
+ if (rect.width < 20 || rect.height < 20) return []
1163
+ const style = getComputedStyle(el)
1164
+ const widths = {}
1165
+ const colors = {}
1166
+ for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
1167
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0
1168
+ colors[s] = style[`border${s}Color`] || ''
1169
+ }
1170
+ const findings = checkBorders(tag, widths, colors)
1171
+ findings.push(...checkRadiusMixedScales(tag, collectCornerRadii(style, rect.width)))
1172
+ return findings
1173
+ }
1174
+
1175
+ export function checkElementColorsDOM(el) {
1176
+ const tag = el.tagName.toLowerCase()
1177
+ const rect = el.getBoundingClientRect()
1178
+ if (rect.width < 10 || rect.height < 10) return []
1179
+ const style = getComputedStyle(el)
1180
+ const directText = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('')
1181
+ const hasDirectText = directText.trim().length > 0
1182
+ return checkColors({
1183
+ tag,
1184
+ textColor: parseRgb(style.color),
1185
+ bgColor: readOwnBackgroundColor(el, style),
1186
+ effectiveBg: resolveBackground(el),
1187
+ fontSize: parseFloat(style.fontSize) || 16,
1188
+ fontWeight: parseInt(style.fontWeight) || 400,
1189
+ hasDirectText,
1190
+ isEmojiOnly: isEmojiOnlyText(directText),
1191
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1192
+ bgImage: style.backgroundImage || '',
1193
+ classList: el.getAttribute('class') || '',
1194
+ })
1195
+ }
1196
+
1197
+ export function checkElementGlowDOM(el) {
1198
+ const tag = el.tagName.toLowerCase()
1199
+ const style = getComputedStyle(el)
1200
+ if (!style.boxShadow || style.boxShadow === 'none') return []
1201
+ const parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el)
1202
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg })
1203
+ }
1204
+
1205
+ export function checkElementMotionDOM(el) {
1206
+ const tag = el.tagName.toLowerCase()
1207
+ if (SAFE_TAGS.has(tag)) return []
1208
+ const style = getComputedStyle(el)
1209
+ return checkMotion({
1210
+ tag,
1211
+ transitionProperty: style.transitionProperty || '',
1212
+ animationName: style.animationName || '',
1213
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1214
+ classList: el.getAttribute('class') || '',
1215
+ })
1216
+ }
1217
+
1218
+ export function checkElementTactilePressDOM(el) {
1219
+ const tag = el.tagName.toLowerCase()
1220
+ const style = getComputedStyle(el)
1221
+ return checkTactilePress(
1222
+ tag,
1223
+ (el.getAttribute('role') || '').toLowerCase(),
1224
+ style.transitionProperty || '',
1225
+ el.getAttribute('class') || '',
1226
+ el.hasAttribute && el.hasAttribute('onclick')
1227
+ )
1228
+ }
1229
+
1230
+ export function checkElementPillOnButtonDOM(el) {
1231
+ const tag = el.tagName.toLowerCase()
1232
+ const style = getComputedStyle(el)
1233
+ const rect = el.getBoundingClientRect()
1234
+ return checkPillOnButton(
1235
+ tag,
1236
+ (el.getAttribute('role') || '').toLowerCase(),
1237
+ resolveBorderRadiusPx(el, style, rect.width),
1238
+ el.getAttribute('class') || ''
1239
+ )
1240
+ }
1241
+
1242
+ export function checkElementLucideIconDOM(el) {
1243
+ if (el.tagName.toLowerCase() !== 'svg') return []
1244
+ return checkLucideIcon(
1245
+ el.getAttribute('viewBox') || '',
1246
+ el.getAttribute('stroke-width') || '',
1247
+ el.getAttribute('stroke-linecap') || '',
1248
+ el.getAttribute('fill') || ''
1249
+ )
1250
+ }
1251
+
1252
+ export function checkElementQualityDOM(el) {
1253
+ const tag = el.tagName.toLowerCase()
1254
+ const style = getComputedStyle(el)
1255
+ const hasDirectText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 10)
1256
+ const textLen = el.textContent?.trim().length || 0
1257
+ const fontSize = parseFloat(style.fontSize) || 16
1258
+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize)
1259
+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize)
1260
+ const rect = el.getBoundingClientRect()
1261
+ const viewportWidth = typeof window !== 'undefined' ? window.innerWidth || 0 : 0
1262
+ return checkQuality({
1263
+ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, viewportWidth,
1264
+ })
1265
+ }
1266
+
1267
+ export function checkPageQualityDOM() {
1268
+ return checkPageQualityFromDoc(document).map((f) => ({ type: f.id, detail: f.snippet }))
1269
+ }
1270
+
1271
+ // ─── Section 6: Node element adapters (static cascade) ──────────────────────
1272
+
1273
+ export function checkElementBorders(tag, style, overrides) {
1274
+ const sides = ['Top', 'Right', 'Bottom', 'Left']
1275
+ const widths = {}
1276
+ const colors = {}
1277
+ for (const s of sides) {
1278
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0
1279
+ colors[s] = style[`border${s}Color`] || ''
1280
+ // The static cascade may drop a var()-bearing border shorthand; the
1281
+ // pre-pass override fills the missing side so the side-stripe check runs.
1282
+ if (widths[s] === 0 && overrides && overrides[s]) {
1283
+ widths[s] = overrides[s].width
1284
+ colors[s] = overrides[s].color
1285
+ } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1286
+ colors[s] = overrides[s].color
1287
+ }
1288
+ }
1289
+ const findings = checkBorders(tag, widths, colors)
1290
+ findings.push(...checkRadiusMixedScales(tag, collectCornerRadii(style, parseFloat(style.width) || 0)))
1291
+ return findings
1292
+ }
1293
+
1294
+ export function checkElementColors(el, style, tag, window) {
1295
+ const directText = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('')
1296
+ const hasDirectText = directText.trim().length > 0
1297
+ return checkColors({
1298
+ tag,
1299
+ textColor: parseRgb(style.color),
1300
+ bgColor: readOwnBackgroundColor(el, style),
1301
+ effectiveBg: resolveBackground(el, window),
1302
+ fontSize: parseFloat(style.fontSize) || 16,
1303
+ fontWeight: parseInt(style.fontWeight) || 400,
1304
+ hasDirectText,
1305
+ isEmojiOnly: isEmojiOnlyText(directText),
1306
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1307
+ bgImage: style.backgroundImage || '',
1308
+ classList: el.getAttribute?.('class') || el.className || '',
1309
+ })
1310
+ }
1311
+
1312
+ export function checkElementGlow(tag, style, effectiveBg) {
1313
+ if (!style.boxShadow || style.boxShadow === 'none') return []
1314
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg })
1315
+ }
1316
+
1317
+ export function checkElementMotion(tag, style) {
1318
+ return checkMotion({
1319
+ tag,
1320
+ transitionProperty: style.transitionProperty || '',
1321
+ animationName: style.animationName || '',
1322
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1323
+ classList: '',
1324
+ })
1325
+ }
1326
+
1327
+ export function checkElementTactilePress(el, style, tag) {
1328
+ return checkTactilePress(
1329
+ tag,
1330
+ (el.getAttribute?.('role') || '').toLowerCase(),
1331
+ style.transitionProperty || '',
1332
+ el.getAttribute?.('class') || '',
1333
+ !!el.getAttribute?.('onclick')
1334
+ )
1335
+ }
1336
+
1337
+ export function checkElementPillOnButton(el, style, tag) {
1338
+ return checkPillOnButton(
1339
+ tag,
1340
+ (el.getAttribute?.('role') || '').toLowerCase(),
1341
+ resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0),
1342
+ el.getAttribute?.('class') || ''
1343
+ )
1344
+ }
1345
+
1346
+ export function checkElementLucideIcon(el, tag) {
1347
+ if (tag !== 'svg') return []
1348
+ return checkLucideIcon(
1349
+ el.getAttribute?.('viewBox') || '',
1350
+ el.getAttribute?.('stroke-width') || '',
1351
+ el.getAttribute?.('stroke-linecap') || '',
1352
+ el.getAttribute?.('fill') || ''
1353
+ )
1354
+ }
1355
+
1356
+ export function checkElementQuality(el, style, tag, window) {
1357
+ const hasDirectText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 10)
1358
+ const textLen = el.textContent?.trim().length || 0
1359
+ const fontSize = resolveFontSizePx(el, window)
1360
+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize)
1361
+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize)
1362
+ // The static cascade does no layout — pass rect: null to skip the three
1363
+ // rect-dependent rules (line-length, cramped-padding, body-text-viewport-edge).
1364
+ return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null })
1365
+ }
1366
+
1367
+ // ─── Section 7: page-level checks ───────────────────────────────────────────
1368
+
1369
+ /** Skipped-heading walk. Works on any Document (browser or static). */
1370
+ export function checkPageQualityFromDoc(doc) {
1371
+ const findings = []
1372
+ const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
1373
+ let prevLevel = 0
1374
+ let prevText = ''
1375
+ for (const h of headings) {
1376
+ const level = parseInt(h.tagName[1])
1377
+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)
1378
+ if (prevLevel > 0 && level > prevLevel + 1) {
1379
+ findings.push({
1380
+ id: 'skipped-heading',
1381
+ snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1382
+ })
1383
+ }
1384
+ prevLevel = level
1385
+ prevText = text
1386
+ }
1387
+ return findings
1388
+ }
1389
+
1390
+ /** Flat-type-hierarchy page check. */
1391
+ export function checkPageTypography(doc, win) {
1392
+ const findings = []
1393
+ const sizes = new Set()
1394
+ for (const el of doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
1395
+ const fontSize = parseFloat(win.getComputedStyle(el).fontSize)
1396
+ if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10)
1397
+ }
1398
+ if (sizes.size >= 3) {
1399
+ const sorted = [...sizes].sort((a, b) => a - b)
1400
+ const ratio = sorted[sorted.length - 1] / sorted[0]
1401
+ if (ratio < 2.0) {
1402
+ findings.push({
1403
+ id: 'flat-type-hierarchy',
1404
+ snippet: `Sizes: ${sorted.map((s) => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`,
1405
+ })
1406
+ }
1407
+ }
1408
+ return findings
1409
+ }
1410
+
1411
+ function isCardLike(el, win) {
1412
+ const tag = el.tagName.toLowerCase()
1413
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) {
1414
+ return false
1415
+ }
1416
+ const style = win.getComputedStyle(el)
1417
+ const rawStyle = el.getAttribute?.('style') || ''
1418
+ const cls = el.getAttribute?.('class') || ''
1419
+ const hasShadow =
1420
+ (style.boxShadow && style.boxShadow !== 'none') ||
1421
+ /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) ||
1422
+ /box-shadow/i.test(rawStyle)
1423
+ const hasBorder = /\bborder\b/.test(cls) || (parseFloat(style.borderTopWidth) || 0) > 0
1424
+ const widthPx = parseFloat(style.width) || 0
1425
+ const hasRadius =
1426
+ resolveBorderRadiusPx(el, style, widthPx) > 0 ||
1427
+ /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) ||
1428
+ /border-radius/i.test(rawStyle)
1429
+ const hasBg =
1430
+ /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1431
+ /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle) ||
1432
+ (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)')
1433
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg)
1434
+ }
1435
+
1436
+ /** Nested-cards and everything-centered page checks. */
1437
+ export function checkPageLayout(doc, win) {
1438
+ const findings = []
1439
+
1440
+ const allEls = doc.querySelectorAll('*')
1441
+ const flaggedEls = new Set()
1442
+ for (const el of allEls) {
1443
+ if (!isCardLike(el, win)) continue
1444
+ if (flaggedEls.has(el)) continue
1445
+ const tag = el.tagName.toLowerCase()
1446
+ const cls = el.getAttribute?.('class') || ''
1447
+ const rawStyle = el.getAttribute?.('style') || ''
1448
+ if (['pre', 'code'].includes(tag)) continue
1449
+ if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue
1450
+ if ((el.textContent?.trim().length || 0) < 10) continue
1451
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue
1452
+ let parent = el.parentElement
1453
+ while (parent) {
1454
+ if (isCardLike(parent, win)) {
1455
+ flaggedEls.add(el)
1456
+ break
1457
+ }
1458
+ parent = parent.parentElement
1459
+ }
1460
+ }
1461
+ for (const el of flaggedEls) {
1462
+ let isAncestorOfFlagged = false
1463
+ for (const other of flaggedEls) {
1464
+ if (other !== el && el.contains(other)) {
1465
+ isAncestorOfFlagged = true
1466
+ break
1467
+ }
1468
+ }
1469
+ if (!isAncestorOfFlagged) {
1470
+ findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` })
1471
+ }
1472
+ }
1473
+
1474
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button')
1475
+ let centeredCount = 0
1476
+ let totalText = 0
1477
+ for (const el of textEls) {
1478
+ const hasDirectText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length >= 3)
1479
+ if (!hasDirectText) continue
1480
+ totalText++
1481
+ let cur = el
1482
+ let isCentered = false
1483
+ while (cur && cur.nodeType === 1) {
1484
+ const rawStyle = cur.getAttribute?.('style') || ''
1485
+ const cls = cur.getAttribute?.('class') || ''
1486
+ if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1487
+ isCentered = true
1488
+ break
1489
+ }
1490
+ if (cur.tagName === 'BODY') break
1491
+ cur = cur.parentElement
1492
+ }
1493
+ if (isCentered) centeredCount++
1494
+ }
1495
+ if (totalText >= 5 && centeredCount / totalText > 0.7) {
1496
+ findings.push({
1497
+ id: 'everything-centered',
1498
+ snippet: `${centeredCount}/${totalText} text elements centered (${Math.round((centeredCount / totalText) * 100)}%)`,
1499
+ })
1500
+ }
1501
+
1502
+ return findings
1503
+ }
1504
+
1505
+ export {
1506
+ checkBorders,
1507
+ checkRadiusMixedScales,
1508
+ checkPillOnButton,
1509
+ checkTactilePress,
1510
+ checkLucideIcon,
1511
+ checkColors,
1512
+ checkGlow,
1513
+ checkMotion,
1514
+ checkQuality,
1515
+ isCardLikeFromProps,
1516
+ isCardLike,
1517
+ readOwnBackgroundColor,
1518
+ }