@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.
- package/.claude/skills/seven/SKILL.md +162 -0
- package/.claude/skills/seven/reference/audit.md +120 -0
- package/.claude/skills/seven/reference/brand.md +24 -0
- package/.claude/skills/seven/reference/clarify.md +76 -0
- package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
- package/.claude/skills/seven/reference/motion-design.md +71 -0
- package/.claude/skills/seven/reference/polish.md +55 -0
- package/.claude/skills/seven/reference/product.md +45 -0
- package/.claude/skills/seven/reference/shape.md +85 -0
- package/.claude/skills/seven/reference/spatial-design.md +60 -0
- package/.claude/skills/seven/reference/typography.md +43 -0
- package/.claude/skills/seven/reference/ux-writing.md +47 -0
- package/.claude/skills/seven/scripts/load-context.mjs +84 -0
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +190 -0
- package/NOTICE.md +26 -0
- package/README.md +118 -0
- package/cli/bin/commands/skills.mjs +664 -0
- package/cli/bin/seven.mjs +68 -0
- package/cli/engine/browser/injected/index.mjs +84 -0
- package/cli/engine/cli/main.mjs +215 -0
- package/cli/engine/detect-antipatterns-browser.js +3014 -0
- package/cli/engine/detect-antipatterns.mjs +44 -0
- package/cli/engine/engines/browser/detect-url.mjs +108 -0
- package/cli/engine/engines/regex/detect-text.mjs +508 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
- package/cli/engine/engines/static-html/detect-html.mjs +211 -0
- package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
- package/cli/engine/findings.mjs +28 -0
- package/cli/engine/node/file-system.mjs +212 -0
- package/cli/engine/profile/profiler.mjs +169 -0
- package/cli/engine/registry/seven-antipatterns.mjs +494 -0
- package/cli/engine/rules/checks.mjs +1518 -0
- package/cli/engine/shared/color.mjs +204 -0
- package/cli/engine/shared/constants.mjs +91 -0
- package/cli/engine/shared/page.mjs +9 -0
- package/cli/engine/shared/tokens.mjs +153 -0
- package/cli/engine/token-data.generated.mjs +691 -0
- package/docs/detector-rules.md +605 -0
- package/docs/figma-token-rule-exploration.md +185 -0
- 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
|
+
}
|