css-slop-detector 0.2.0__py3-none-any.whl
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.
- css_slop.py +633 -0
- css_slop_detector-0.2.0.dist-info/METADATA +132 -0
- css_slop_detector-0.2.0.dist-info/RECORD +7 -0
- css_slop_detector-0.2.0.dist-info/WHEEL +5 -0
- css_slop_detector-0.2.0.dist-info/entry_points.txt +2 -0
- css_slop_detector-0.2.0.dist-info/licenses/LICENSE +21 -0
- css_slop_detector-0.2.0.dist-info/top_level.txt +1 -0
css_slop.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""css-slop-detector: score CSS/HTML for AI-generated design cliches.
|
|
3
|
+
|
|
4
|
+
Detects the visual fingerprints of AI-generated web design: oversized
|
|
5
|
+
border radii, glassmorphism-everywhere, purple gradients, neon glows,
|
|
6
|
+
pure-black dark mode with no light toggle, and giant centered hero titles.
|
|
7
|
+
|
|
8
|
+
v2 adds --fix: an automatic rewrite loop that de-slops the flagged
|
|
9
|
+
patterns (with backups and dry-run diff previews).
|
|
10
|
+
|
|
11
|
+
Standard library only. No dependencies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import difflib
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
VERSION = "0.2.0"
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------- checks
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Finding:
|
|
32
|
+
rule: str
|
|
33
|
+
line: int
|
|
34
|
+
message: str
|
|
35
|
+
weight: int
|
|
36
|
+
snippet: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class FileReport:
|
|
41
|
+
path: str
|
|
42
|
+
findings: list = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def score(self) -> int:
|
|
46
|
+
# weights are severity points; scale to a 0-100 slop score
|
|
47
|
+
return min(100, sum(f.weight for f in self.findings) * 4)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def label(self) -> str:
|
|
51
|
+
s = self.score
|
|
52
|
+
if s <= 20:
|
|
53
|
+
return "clean"
|
|
54
|
+
if s <= 50:
|
|
55
|
+
return "mild slop"
|
|
56
|
+
if s <= 80:
|
|
57
|
+
return "slop"
|
|
58
|
+
return "peak slop"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_PURPLE_RE = re.compile(
|
|
62
|
+
r"purple|violet|indigo|fuchsia|magenta|rebeccapurple"
|
|
63
|
+
r"|#8b5cf6|#a855f7|#7c3aed|#6d28d9|#d946ef|#c026d3",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
_RADIUS_RE = re.compile(r"border-radius\s*:\s*(\d+(?:\.\d+)?)\s*(px|rem|em)?", re.IGNORECASE)
|
|
67
|
+
_GLASS_RE = re.compile(r"backdrop-filter\s*:[^;]*blur", re.IGNORECASE)
|
|
68
|
+
_GRADIENT_RE = re.compile(r"(linear|radial|conic)-gradient\s*\(([^;{}]*)\)", re.IGNORECASE)
|
|
69
|
+
_GLOW_RE = re.compile(
|
|
70
|
+
r"box-shadow\s*:[^;]*?(\d+(?:\.\d+)?)\s*(?:px)?\s+"
|
|
71
|
+
r"(\d+(?:\.\d+)?)\s*(?:px)?\s+(\d+(?:\.\d+)?)\s*px", re.IGNORECASE
|
|
72
|
+
)
|
|
73
|
+
_GLOW_COLOR_RE = re.compile(
|
|
74
|
+
r"box-shadow\s*:[^;]*(rgba?\s*\([^)]*\)|#[0-9a-f]{3,8})", re.IGNORECASE
|
|
75
|
+
)
|
|
76
|
+
_NEON_NAMED_COLORS = {
|
|
77
|
+
"red", "cyan", "magenta", "fuchsia", "lime", "yellow", "orange",
|
|
78
|
+
"purple", "violet", "indigo", "blue", "green", "pink", "hotpink",
|
|
79
|
+
"deeppink", "teal", "aqua", "chartreuse",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_shadow_rgb(color: str):
|
|
84
|
+
"""Return (r, g, b) floats for hex / rgb() / rgba() colors, else None."""
|
|
85
|
+
color = color.strip()
|
|
86
|
+
hm = re.fullmatch(r"#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})",
|
|
87
|
+
color, re.IGNORECASE)
|
|
88
|
+
if hm:
|
|
89
|
+
h = hm.group(1)
|
|
90
|
+
if len(h) in (3, 4):
|
|
91
|
+
h = "".join(c * 2 for c in h)
|
|
92
|
+
return (float(int(h[0:2], 16)), float(int(h[2:4], 16)),
|
|
93
|
+
float(int(h[4:6], 16)))
|
|
94
|
+
mm = re.fullmatch(r"rgba?\(\s*([^)]*)\)", color, re.IGNORECASE)
|
|
95
|
+
if mm:
|
|
96
|
+
vals = []
|
|
97
|
+
for part in mm.group(1).split(",")[:3]:
|
|
98
|
+
part = part.strip()
|
|
99
|
+
try:
|
|
100
|
+
vals.append(float(part[:-1]) * 255.0 / 100.0
|
|
101
|
+
if part.endswith("%") else float(part))
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
if len(vals) == 3:
|
|
105
|
+
return tuple(vals)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_neon_color(color: str) -> bool:
|
|
110
|
+
"""A glow only counts as 'neon' if its color is vivid (saturated).
|
|
111
|
+
|
|
112
|
+
Plain black/gray/white shadows (e.g. Tailwind's large soft shadow
|
|
113
|
+
`rgba(0,0,0,.25)`) are normal depth cues, not neon glows.
|
|
114
|
+
"""
|
|
115
|
+
rgb = _parse_shadow_rgb(color)
|
|
116
|
+
if rgb is None:
|
|
117
|
+
return color.strip().lower() in _NEON_NAMED_COLORS
|
|
118
|
+
r, g, b = rgb
|
|
119
|
+
return (max(r, g, b) - min(r, g, b)) >= 80 and max(r, g, b) >= 100
|
|
120
|
+
_BG_DECL_RE = re.compile(r"background(?:-color)?\s*:\s*([^;{}]+)", re.IGNORECASE)
|
|
121
|
+
_HEX_COLOR_RE = re.compile(r"#([0-9a-f]{3}|[0-9a-f]{6})\b", re.IGNORECASE)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_near_black_hex(hexcode: str) -> bool:
|
|
125
|
+
"""True only if every channel is very dark (rules out #0066cc blue etc)."""
|
|
126
|
+
h = hexcode.lstrip("#")
|
|
127
|
+
if len(h) == 3:
|
|
128
|
+
h = "".join(c * 2 for c in h)
|
|
129
|
+
if len(h) != 6:
|
|
130
|
+
return False
|
|
131
|
+
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
132
|
+
return max(r, g, b) <= 0x1A
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _black_bg_value(block: str) -> str | None:
|
|
136
|
+
"""Return the near-black background value in a declaration block, if any."""
|
|
137
|
+
for m in _BG_DECL_RE.finditer(block):
|
|
138
|
+
val = m.group(1)
|
|
139
|
+
if re.search(r"\bblack\b", val, re.IGNORECASE):
|
|
140
|
+
return "black"
|
|
141
|
+
for hm in _HEX_COLOR_RE.finditer(val):
|
|
142
|
+
if _is_near_black_hex(hm.group(0)):
|
|
143
|
+
return hm.group(0)
|
|
144
|
+
return None
|
|
145
|
+
_CENTER_RE = re.compile(r"text-align\s*:\s*center", re.IGNORECASE)
|
|
146
|
+
_BIG_TEXT_RE = re.compile(r"font-size\s*:\s*(\d+(?:\.\d+)?)\s*(px|rem|em)?", re.IGNORECASE)
|
|
147
|
+
_LIGHT_MODE_RE = re.compile(
|
|
148
|
+
r"prefers-color-scheme\s*:\s*light|data-theme\s*=\s*[\"']light[\"']"
|
|
149
|
+
r"|\.light-theme|theme-toggle|color-scheme\s*:\s*light",
|
|
150
|
+
re.IGNORECASE,
|
|
151
|
+
)
|
|
152
|
+
_RULE_RE = re.compile(r"([^{}]+)\{([^{}]*)\}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _px(value: float, unit: str | None) -> float:
|
|
156
|
+
if (unit or "px").lower() == "px":
|
|
157
|
+
return value
|
|
158
|
+
return value * 16 # rem/em -> px approximation
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def check_rule_block(selector: str, block: str, line: int) -> list[Finding]:
|
|
162
|
+
out: list[Finding] = []
|
|
163
|
+
|
|
164
|
+
for m in _RADIUS_RE.finditer(block):
|
|
165
|
+
if _px(float(m.group(1)), m.group(2)) >= 20:
|
|
166
|
+
out.append(Finding(
|
|
167
|
+
"big-radius", line,
|
|
168
|
+
f"oversized border-radius ({m.group(1)}{m.group(2) or 'px'}) "
|
|
169
|
+
f"on '{selector.strip()}'",
|
|
170
|
+
2, m.group(0)))
|
|
171
|
+
|
|
172
|
+
if _GLASS_RE.search(block):
|
|
173
|
+
out.append(Finding(
|
|
174
|
+
"glass", line,
|
|
175
|
+
f"backdrop-filter blur on '{selector.strip()}' (glassmorphism)",
|
|
176
|
+
2, "backdrop-filter: blur(...)"))
|
|
177
|
+
|
|
178
|
+
for m in _GRADIENT_RE.finditer(block):
|
|
179
|
+
if _PURPLE_RE.search(m.group(2)):
|
|
180
|
+
out.append(Finding(
|
|
181
|
+
"purple-gradient", line,
|
|
182
|
+
f"purple/violet gradient on '{selector.strip()}'",
|
|
183
|
+
3, m.group(0)[:80]))
|
|
184
|
+
|
|
185
|
+
for m in _GLOW_RE.finditer(block):
|
|
186
|
+
if float(m.group(3)) < 24:
|
|
187
|
+
continue
|
|
188
|
+
cm = _GLOW_COLOR_RE.search(block)
|
|
189
|
+
if cm and _is_neon_color(cm.group(1)):
|
|
190
|
+
out.append(Finding(
|
|
191
|
+
"glow-shadow", line,
|
|
192
|
+
f"neon glow box-shadow (blur {m.group(3)}px) "
|
|
193
|
+
f"on '{selector.strip()}'",
|
|
194
|
+
2, m.group(0)[:80]))
|
|
195
|
+
|
|
196
|
+
black_bg = _black_bg_value(block)
|
|
197
|
+
if black_bg:
|
|
198
|
+
out.append(Finding(
|
|
199
|
+
"pure-black", line,
|
|
200
|
+
f"pure/near-black background ({black_bg}) on '{selector.strip()}'",
|
|
201
|
+
2, f"background: {black_bg}"))
|
|
202
|
+
|
|
203
|
+
big_text = None
|
|
204
|
+
for m in _BIG_TEXT_RE.finditer(block):
|
|
205
|
+
if _px(float(m.group(1)), m.group(2)) >= 40:
|
|
206
|
+
big_text = m.group(0)
|
|
207
|
+
if big_text and _CENTER_RE.search(block):
|
|
208
|
+
out.append(Finding(
|
|
209
|
+
"hero-center", line,
|
|
210
|
+
f"giant centered title ({big_text}) on '{selector.strip()}'",
|
|
211
|
+
3, big_text))
|
|
212
|
+
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def scan_css_text(css: str, path: str, base_line: int = 0) -> FileReport:
|
|
217
|
+
report = FileReport(path)
|
|
218
|
+
for m in _RULE_RE.finditer(css):
|
|
219
|
+
selector, block = m.group(1), m.group(2)
|
|
220
|
+
# m.start() includes the selector's leading whitespace/newlines, so
|
|
221
|
+
# measure from the opening brace for an accurate line number.
|
|
222
|
+
brace_pos = m.start() + len(m.group(1))
|
|
223
|
+
line = base_line + css.count("\n", 0, brace_pos) + 1
|
|
224
|
+
report.findings.extend(check_rule_block(selector, block, line))
|
|
225
|
+
if not _LIGHT_MODE_RE.search(css) and _black_bg_value(css):
|
|
226
|
+
report.findings.append(Finding(
|
|
227
|
+
"no-light-mode", 1,
|
|
228
|
+
"dark-only design: near-black backgrounds with no light-mode "
|
|
229
|
+
"toggle or prefers-color-scheme: light found",
|
|
230
|
+
5, ""))
|
|
231
|
+
return report
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_STYLE_TAG_RE = re.compile(r"<style[^>]*>(.*?)</style>", re.IGNORECASE | re.DOTALL)
|
|
235
|
+
_INLINE_STYLE_RE = re.compile(r'style\s*=\s*"([^"]*)"', re.IGNORECASE)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def scan_html_text(html: str, path: str) -> FileReport:
|
|
239
|
+
report = FileReport(path)
|
|
240
|
+
for m in _STYLE_TAG_RE.finditer(html):
|
|
241
|
+
base_line = html.count("\n", 0, m.start(1))
|
|
242
|
+
sub = scan_css_text(m.group(1), path, base_line)
|
|
243
|
+
report.findings.extend(sub.findings)
|
|
244
|
+
# inline styles: wrap as a synthetic rule so block checks apply
|
|
245
|
+
for m in _INLINE_STYLE_RE.finditer(html):
|
|
246
|
+
line = html.count("\n", 0, m.start()) + 1
|
|
247
|
+
fake = f".inline{{{m.group(1)}}}"
|
|
248
|
+
sub = scan_css_text(fake, path, line - 1)
|
|
249
|
+
for f in sub.findings:
|
|
250
|
+
f.snippet = (f.snippet or "")[:60]
|
|
251
|
+
report.findings.extend(sub.findings)
|
|
252
|
+
if not _LIGHT_MODE_RE.search(html) and _black_bg_value(html):
|
|
253
|
+
report.findings.append(Finding(
|
|
254
|
+
"no-light-mode", 1,
|
|
255
|
+
"dark-only design: near-black backgrounds with no light-mode "
|
|
256
|
+
"toggle found",
|
|
257
|
+
5, ""))
|
|
258
|
+
return report
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def scan_path(path: str) -> FileReport:
|
|
262
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
263
|
+
text = fh.read()
|
|
264
|
+
if path.lower().endswith((".html", ".htm")):
|
|
265
|
+
return scan_html_text(text, path)
|
|
266
|
+
return scan_css_text(text, path)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def scan_text(text: str, path: str):
|
|
270
|
+
if path.lower().endswith((".html", ".htm")):
|
|
271
|
+
return scan_html_text(text, path)
|
|
272
|
+
return scan_css_text(text, path)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------- fixes (v2)
|
|
276
|
+
|
|
277
|
+
# The fixer mirrors the detectors: every automatic rewrite only touches a
|
|
278
|
+
# pattern that the corresponding check would flag. Fixes are deterministic
|
|
279
|
+
# and conservative — they de-slop, they don't redesign.
|
|
280
|
+
|
|
281
|
+
_PURPLE_HEX_FIX = {
|
|
282
|
+
"#8b5cf6": "#64748b", "#a855f7": "#94a3b8", "#7c3aed": "#64748b",
|
|
283
|
+
"#6d28d9": "#475569", "#d946ef": "#94a3b8", "#c026d3": "#64748b",
|
|
284
|
+
}
|
|
285
|
+
_PURPLE_NAME_FIX = {
|
|
286
|
+
"purple": "#64748b", "violet": "#94a3b8", "indigo": "#64748b",
|
|
287
|
+
"fuchsia": "#94a3b8", "magenta": "#94a3b8", "rebeccapurple": "#64748b",
|
|
288
|
+
}
|
|
289
|
+
_PURPLE_NAME_FIX_RE = re.compile(
|
|
290
|
+
r"\b(" + "|".join(sorted(_PURPLE_NAME_FIX, key=len, reverse=True)) + r")\b",
|
|
291
|
+
re.IGNORECASE,
|
|
292
|
+
)
|
|
293
|
+
_PURPLE_HEX_FIX_RE = re.compile(
|
|
294
|
+
r"(" + "|".join(re.escape(h) for h in _PURPLE_HEX_FIX) + r")\b",
|
|
295
|
+
re.IGNORECASE,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
_GLASS_DECL_RE = re.compile(r"backdrop-filter\s*:[^;{}]*blur[^;{}]*;?",
|
|
299
|
+
re.IGNORECASE)
|
|
300
|
+
_GLOW_DECL_RE = re.compile(r"box-shadow\s*:[^;{}]+;?", re.IGNORECASE)
|
|
301
|
+
_BG_FIX_RE = re.compile(r"(background(?:-color)?\s*:\s*)([^;{}]+)",
|
|
302
|
+
re.IGNORECASE)
|
|
303
|
+
_BLACK_WORD_RE = re.compile(r"\bblack\b", re.IGNORECASE)
|
|
304
|
+
|
|
305
|
+
# De-slop rewrite targets. The point is not a redesign — just kill the
|
|
306
|
+
# tell-tale AI fingerprints: clamp the giant radius, drop the glass blur,
|
|
307
|
+
# neutralize the purple gradient and neon glow, lift pure-black surfaces,
|
|
308
|
+
# tame the giant centered hero title, and add a light-mode hook.
|
|
309
|
+
FIX_RADIUS_PX = 12
|
|
310
|
+
FIX_HERO_FONT_PX = 32
|
|
311
|
+
FIX_BLACK_HEX = "#1e1e1e" # lifted soft black, no longer "pure"
|
|
312
|
+
FIX_SOFT_SHADOW = "box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1)"
|
|
313
|
+
LIGHT_MODE_STUB = (
|
|
314
|
+
"@media (prefers-color-scheme: light) {\n"
|
|
315
|
+
" /* added by css-slop-detector --fix: light-mode hook */\n"
|
|
316
|
+
" :root { color-scheme: light; }\n"
|
|
317
|
+
"}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _fix_purple_colors(body: str) -> tuple[str, int]:
|
|
322
|
+
"""Swap purple/violet gradient stops for neutral slate. Returns (new, n)."""
|
|
323
|
+
n = [0]
|
|
324
|
+
|
|
325
|
+
def hex_repl(m):
|
|
326
|
+
n[0] += 1
|
|
327
|
+
return _PURPLE_HEX_FIX[m.group(0).lower()]
|
|
328
|
+
|
|
329
|
+
def name_repl(m):
|
|
330
|
+
n[0] += 1
|
|
331
|
+
return _PURPLE_NAME_FIX[m.group(0).lower()]
|
|
332
|
+
|
|
333
|
+
body = _PURPLE_HEX_FIX_RE.sub(hex_repl, body)
|
|
334
|
+
body = _PURPLE_NAME_FIX_RE.sub(name_repl, body)
|
|
335
|
+
return body, n[0]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _fix_black_value(val: str) -> tuple[str, int]:
|
|
339
|
+
"""Lift pure/near-black background values to a softer black."""
|
|
340
|
+
n = [0]
|
|
341
|
+
|
|
342
|
+
def hex_repl(m):
|
|
343
|
+
if _is_near_black_hex(m.group(0)):
|
|
344
|
+
n[0] += 1
|
|
345
|
+
return FIX_BLACK_HEX
|
|
346
|
+
return m.group(0)
|
|
347
|
+
|
|
348
|
+
val = _HEX_COLOR_RE.sub(hex_repl, val)
|
|
349
|
+
|
|
350
|
+
def word_repl(m):
|
|
351
|
+
n[0] += 1
|
|
352
|
+
return FIX_BLACK_HEX
|
|
353
|
+
|
|
354
|
+
val = _BLACK_WORD_RE.sub(word_repl, val)
|
|
355
|
+
return val, n[0]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _fix_block(block: str, fixes: Counter) -> str:
|
|
359
|
+
"""Apply block-level rewrites to one declaration block. Returns new block."""
|
|
360
|
+
|
|
361
|
+
# big-radius -> clamp to FIX_RADIUS_PX
|
|
362
|
+
def radius_repl(m):
|
|
363
|
+
if _px(float(m.group(1)), m.group(2)) >= 20:
|
|
364
|
+
fixes["big-radius"] += 1
|
|
365
|
+
return f"border-radius: {FIX_RADIUS_PX}px"
|
|
366
|
+
return m.group(0)
|
|
367
|
+
|
|
368
|
+
block = _RADIUS_RE.sub(radius_repl, block)
|
|
369
|
+
|
|
370
|
+
# glass -> remove the backdrop-filter blur declaration
|
|
371
|
+
def glass_repl(m):
|
|
372
|
+
fixes["glass"] += 1
|
|
373
|
+
return ""
|
|
374
|
+
|
|
375
|
+
block = _GLASS_DECL_RE.sub(glass_repl, block)
|
|
376
|
+
block = re.sub(r";\s*;", ";", block)
|
|
377
|
+
|
|
378
|
+
# purple-gradient -> neutral slate gradient stops
|
|
379
|
+
def grad_repl(m):
|
|
380
|
+
new_body, count = _fix_purple_colors(m.group(2))
|
|
381
|
+
if count:
|
|
382
|
+
fixes["purple-gradient"] += count
|
|
383
|
+
return f"{m.group(1)}-gradient({new_body})"
|
|
384
|
+
return m.group(0)
|
|
385
|
+
|
|
386
|
+
block = _GRADIENT_RE.sub(grad_repl, block)
|
|
387
|
+
|
|
388
|
+
# glow-shadow -> soft neutral shadow (only neon glows, like the detector)
|
|
389
|
+
def glow_repl(m):
|
|
390
|
+
decl = m.group(0)
|
|
391
|
+
gm = _GLOW_RE.search(decl)
|
|
392
|
+
if not gm or float(gm.group(3)) < 24:
|
|
393
|
+
return decl
|
|
394
|
+
cm = _GLOW_COLOR_RE.search(decl)
|
|
395
|
+
if cm and _is_neon_color(cm.group(1)):
|
|
396
|
+
fixes["glow-shadow"] += 1
|
|
397
|
+
return FIX_SOFT_SHADOW
|
|
398
|
+
return decl
|
|
399
|
+
|
|
400
|
+
block = _GLOW_DECL_RE.sub(glow_repl, block)
|
|
401
|
+
|
|
402
|
+
# pure-black -> lift to a softer black
|
|
403
|
+
def bg_repl(m):
|
|
404
|
+
new_val, count = _fix_black_value(m.group(2))
|
|
405
|
+
if count:
|
|
406
|
+
fixes["pure-black"] += count
|
|
407
|
+
return m.group(1) + new_val
|
|
408
|
+
return m.group(0)
|
|
409
|
+
|
|
410
|
+
block = _BG_FIX_RE.sub(bg_repl, block)
|
|
411
|
+
|
|
412
|
+
# hero-center -> tame the giant centered title
|
|
413
|
+
big_font = any(
|
|
414
|
+
_px(float(m.group(1)), m.group(2)) >= 40
|
|
415
|
+
for m in _BIG_TEXT_RE.finditer(block)
|
|
416
|
+
)
|
|
417
|
+
if big_font and _CENTER_RE.search(block):
|
|
418
|
+
def size_repl(m):
|
|
419
|
+
if _px(float(m.group(1)), m.group(2)) >= 40:
|
|
420
|
+
return f"font-size: {FIX_HERO_FONT_PX}px"
|
|
421
|
+
return m.group(0)
|
|
422
|
+
|
|
423
|
+
block = _BIG_TEXT_RE.sub(size_repl, block)
|
|
424
|
+
block = _CENTER_RE.sub("text-align: left", block)
|
|
425
|
+
fixes["hero-center"] += 1
|
|
426
|
+
|
|
427
|
+
return block
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def fix_css_text(css: str, fixes: Counter) -> str:
|
|
431
|
+
"""Apply block-level fixes to raw CSS text, recording counts in fixes."""
|
|
432
|
+
|
|
433
|
+
def rule_repl(m):
|
|
434
|
+
return m.group(1) + "{" + _fix_block(m.group(2), fixes) + "}"
|
|
435
|
+
|
|
436
|
+
return _RULE_RE.sub(rule_repl, css)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _append_light_stub(text: str, fixes: Counter, wrap_html: bool) -> str:
|
|
440
|
+
stub = LIGHT_MODE_STUB
|
|
441
|
+
if wrap_html:
|
|
442
|
+
stub = "<style>\n" + stub + "\n</style>"
|
|
443
|
+
text = text.rstrip("\n") + "\n\n" + stub + "\n"
|
|
444
|
+
fixes["no-light-mode"] += 1
|
|
445
|
+
return text
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def apply_fixes(text: str, path: str) -> tuple[str, Counter]:
|
|
449
|
+
"""Rewrite slop patterns in text. Returns (new_text, fix_counts).
|
|
450
|
+
|
|
451
|
+
Only rewrites patterns the detectors would flag. Safe to run on
|
|
452
|
+
already-clean files (returns the text unchanged).
|
|
453
|
+
"""
|
|
454
|
+
html = path.lower().endswith((".html", ".htm"))
|
|
455
|
+
fixes: Counter = Counter()
|
|
456
|
+
# The no-light-mode finding is computed on the original text, before
|
|
457
|
+
# pure-black lifting (lifting the blacks would hide it).
|
|
458
|
+
needs_light = _black_bg_value(text) is not None and not _LIGHT_MODE_RE.search(text)
|
|
459
|
+
|
|
460
|
+
if html:
|
|
461
|
+
def style_repl(m):
|
|
462
|
+
inner = fix_css_text(m.group(1), fixes)
|
|
463
|
+
return m.group(0)[: m.start(1) - m.start()] + inner \
|
|
464
|
+
+ m.group(0)[m.end(1) - m.start():]
|
|
465
|
+
|
|
466
|
+
text = _STYLE_TAG_RE.sub(style_repl, text)
|
|
467
|
+
|
|
468
|
+
def inline_repl(m):
|
|
469
|
+
return 'style="' + _fix_block(m.group(1), fixes) + '"'
|
|
470
|
+
|
|
471
|
+
text = _INLINE_STYLE_RE.sub(inline_repl, text)
|
|
472
|
+
else:
|
|
473
|
+
text = fix_css_text(text, fixes)
|
|
474
|
+
|
|
475
|
+
if needs_light and not _LIGHT_MODE_RE.search(text):
|
|
476
|
+
text = _append_light_stub(text, fixes, wrap_html=html)
|
|
477
|
+
|
|
478
|
+
return text, fixes
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def unified_diff(path: str, old: str, new: str) -> str:
|
|
482
|
+
return "".join(difflib.unified_diff(
|
|
483
|
+
old.splitlines(keepends=True), new.splitlines(keepends=True),
|
|
484
|
+
fromfile=f"a/{path}", tofile=f"b/{path}"))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def fix_file(path: str, *, dry_run: bool, backup_suffix: str) -> dict:
|
|
488
|
+
"""Preview or apply fixes for one file. Returns a result dict."""
|
|
489
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
490
|
+
original = fh.read()
|
|
491
|
+
before = scan_text(original, path)
|
|
492
|
+
new_text, fixes = apply_fixes(original, path)
|
|
493
|
+
after = scan_text(new_text, path) if new_text != original else before
|
|
494
|
+
|
|
495
|
+
result = {
|
|
496
|
+
"path": path,
|
|
497
|
+
"fixes": dict(fixes),
|
|
498
|
+
"fix_count": sum(fixes.values()),
|
|
499
|
+
"score_before": before.score,
|
|
500
|
+
"score_after": after.score,
|
|
501
|
+
"diff": unified_diff(path, original, new_text),
|
|
502
|
+
"wrote": False,
|
|
503
|
+
"backup": None,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if dry_run or new_text == original:
|
|
507
|
+
return result
|
|
508
|
+
|
|
509
|
+
backup = path + backup_suffix
|
|
510
|
+
shutil.copy2(path, backup)
|
|
511
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
512
|
+
fh.write(new_text)
|
|
513
|
+
result["wrote"] = True
|
|
514
|
+
result["backup"] = backup
|
|
515
|
+
return result
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def restore_file(path: str, backup_suffix: str) -> dict:
|
|
519
|
+
backup = path + backup_suffix
|
|
520
|
+
try:
|
|
521
|
+
shutil.copy2(backup, path)
|
|
522
|
+
except OSError as e:
|
|
523
|
+
return {"path": path, "restored": False, "error": str(e)}
|
|
524
|
+
return {"path": path, "restored": True, "backup": backup}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---------------------------------------------------------------- cli
|
|
528
|
+
|
|
529
|
+
def fmt_human(report: FileReport) -> str:
|
|
530
|
+
lines = [f"{report.path}: score {report.score}/100 ({report.label})"]
|
|
531
|
+
for f in report.findings:
|
|
532
|
+
lines.append(f" L{f.line} [{f.rule}] {f.message}")
|
|
533
|
+
if not report.findings:
|
|
534
|
+
lines.append(" no slop detected")
|
|
535
|
+
return "\n".join(lines)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def fmt_fix_human(res: dict, dry_run: bool) -> str:
|
|
539
|
+
if not res["fix_count"]:
|
|
540
|
+
return f"{res['path']}: no fixes needed (score {res['score_before']}/100)"
|
|
541
|
+
bits = ", ".join(f"{rule} x{n}" for rule, n in sorted(res["fixes"].items()))
|
|
542
|
+
head = (f"{res['path']}: {res['fix_count']} fixes "
|
|
543
|
+
f"({bits}) — score {res['score_before']} -> {res['score_after']}")
|
|
544
|
+
if dry_run:
|
|
545
|
+
head += " [dry-run, nothing written]"
|
|
546
|
+
elif res["wrote"]:
|
|
547
|
+
head += f" [applied, backup: {res['backup']}]"
|
|
548
|
+
return head + "\n" + res["diff"]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def main(argv: list[str] | None = None) -> int:
|
|
552
|
+
ap = argparse.ArgumentParser(
|
|
553
|
+
prog="css-slop",
|
|
554
|
+
description="Score CSS/HTML for AI-generated design cliches "
|
|
555
|
+
"(v2: --fix rewrites the slop automatically).")
|
|
556
|
+
ap.add_argument("paths", nargs="+", help="CSS or HTML files to scan")
|
|
557
|
+
ap.add_argument("--json", action="store_true", help="emit JSON report")
|
|
558
|
+
ap.add_argument("--fail-over", type=int, default=None, metavar="N",
|
|
559
|
+
help="exit 1 if any file scores N or higher")
|
|
560
|
+
ap.add_argument("--fix", action="store_true",
|
|
561
|
+
help="apply automatic de-slop rewrites (backs up originals)")
|
|
562
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
563
|
+
help="preview fixes as a diff without writing anything")
|
|
564
|
+
ap.add_argument("--restore", action="store_true",
|
|
565
|
+
help="restore files from their --fix backups")
|
|
566
|
+
ap.add_argument("--backup-suffix", default=".bak", metavar="SUFFIX",
|
|
567
|
+
help="backup file suffix for --fix (default: .bak)")
|
|
568
|
+
ap.add_argument("--version", action="store_true",
|
|
569
|
+
help="print version and exit")
|
|
570
|
+
args = ap.parse_args(argv)
|
|
571
|
+
|
|
572
|
+
if args.version:
|
|
573
|
+
print(f"css-slop-detector {VERSION}")
|
|
574
|
+
return 0
|
|
575
|
+
|
|
576
|
+
if args.restore:
|
|
577
|
+
results = [restore_file(p, args.backup_suffix) for p in args.paths]
|
|
578
|
+
if args.json:
|
|
579
|
+
print(json.dumps(results, indent=2))
|
|
580
|
+
else:
|
|
581
|
+
for r in results:
|
|
582
|
+
if r["restored"]:
|
|
583
|
+
print(f"{r['path']}: restored from {r['backup']}")
|
|
584
|
+
else:
|
|
585
|
+
print(f"{r['path']}: restore failed: {r.get('error')}",
|
|
586
|
+
file=sys.stderr)
|
|
587
|
+
return 0 if all(r["restored"] for r in results) else 2
|
|
588
|
+
|
|
589
|
+
if args.fix or args.dry_run:
|
|
590
|
+
results = []
|
|
591
|
+
for p in args.paths:
|
|
592
|
+
try:
|
|
593
|
+
results.append(fix_file(p, dry_run=args.dry_run,
|
|
594
|
+
backup_suffix=args.backup_suffix))
|
|
595
|
+
except OSError as e:
|
|
596
|
+
print(f"error: {p}: {e}", file=sys.stderr)
|
|
597
|
+
return 2
|
|
598
|
+
if args.json:
|
|
599
|
+
print(json.dumps([{k: v for k, v in r.items() if k != "diff"}
|
|
600
|
+
for r in results], indent=2))
|
|
601
|
+
else:
|
|
602
|
+
print("\n\n".join(fmt_fix_human(r, args.dry_run)
|
|
603
|
+
for r in results))
|
|
604
|
+
if args.fail_over is not None and any(
|
|
605
|
+
r["score_after"] >= args.fail_over for r in results):
|
|
606
|
+
return 1
|
|
607
|
+
return 0
|
|
608
|
+
|
|
609
|
+
reports = []
|
|
610
|
+
for p in args.paths:
|
|
611
|
+
try:
|
|
612
|
+
reports.append(scan_path(p))
|
|
613
|
+
except OSError as e:
|
|
614
|
+
print(f"error: {p}: {e}", file=sys.stderr)
|
|
615
|
+
return 2
|
|
616
|
+
|
|
617
|
+
if args.json:
|
|
618
|
+
print(json.dumps([
|
|
619
|
+
{"path": r.path, "score": r.score, "label": r.label,
|
|
620
|
+
"findings": [{"rule": f.rule, "line": f.line,
|
|
621
|
+
"message": f.message, "weight": f.weight}
|
|
622
|
+
for f in r.findings]}
|
|
623
|
+
for r in reports], indent=2))
|
|
624
|
+
else:
|
|
625
|
+
print("\n\n".join(fmt_human(r) for r in reports))
|
|
626
|
+
|
|
627
|
+
if args.fail_over is not None and any(r.score >= args.fail_over for r in reports):
|
|
628
|
+
return 1
|
|
629
|
+
return 0
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
if __name__ == "__main__":
|
|
633
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: css-slop-detector
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Score CSS/HTML for AI-generated design cliches — and auto-fix them with --fix
|
|
5
|
+
Author-email: hao li <haoli5933@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/css-slop-detector
|
|
8
|
+
Project-URL: Repository, https://github.com/hahahahahahahahah6/css-slop-detector
|
|
9
|
+
Keywords: css,ai-slop,design,linter,html
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# css-slop-detector
|
|
22
|
+
|
|
23
|
+
Score your CSS/HTML for **AI-generated design cliches** — the "AI slop" look:
|
|
24
|
+
oversized border radii, glassmorphism on everything, purple gradients, neon
|
|
25
|
+
glows, pure-black dark mode with no light toggle, and giant centered hero
|
|
26
|
+
titles.
|
|
27
|
+
|
|
28
|
+
If AI built your site, this tells you in one number how obvious it is.
|
|
29
|
+
**v2 adds `--fix`: a one-click detect-and-fix loop that rewrites the slop
|
|
30
|
+
for you** (with backups and dry-run diff previews).
|
|
31
|
+
|
|
32
|
+
Zero dependencies. Python standard library only.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install css-slop-detector
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or run the single file straight from the repo:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/hahahahahahahahah6/css-slop-detector
|
|
44
|
+
cd css-slop-detector
|
|
45
|
+
python3 css_slop.py scan style.css
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requires Python 3.9+.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Scan
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ css-slop styles.css
|
|
56
|
+
styles.css: score 68/100 (slop)
|
|
57
|
+
L12 [big-radius] oversized border-radius (32px) on '.card'
|
|
58
|
+
L12 [glass] backdrop-filter blur on '.card' (glassmorphism)
|
|
59
|
+
L15 [purple-gradient] purple/violet gradient on '.hero'
|
|
60
|
+
L22 [hero-center] giant centered title (font-size: 72px) on '.hero h1'
|
|
61
|
+
L1 [no-light-mode] dark-only design: near-black backgrounds with no
|
|
62
|
+
light-mode toggle or prefers-color-scheme: light found
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Works on `.css` files and `.html` files (scans `<style>` blocks and inline
|
|
66
|
+
`style="..."` attributes). Machine-readable output with `--json`; fail CI
|
|
67
|
+
with `--fail-over 50` (exit 1 when any file scores 50 or higher).
|
|
68
|
+
|
|
69
|
+
### Fix (v2)
|
|
70
|
+
|
|
71
|
+
Preview what the fixer would change — writes nothing:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
$ css-slop --dry-run styles.css
|
|
75
|
+
styles.css: 7 fixes (big-radius x1, glass x1, glow-shadow x1, hero-center x1,
|
|
76
|
+
no-light-mode x1, pure-black x1, purple-gradient x2) — score 100 -> 0 [dry-run, nothing written]
|
|
77
|
+
--- a/styles.css
|
|
78
|
+
+++ b/styles.css
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Apply the fixes. The original is backed up to `styles.css.bak` first:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
$ css-slop --fix styles.css
|
|
86
|
+
styles.css: 7 fixes (...) — score 100 -> 0 [applied, backup: styles.css.bak]
|
|
87
|
+
|
|
88
|
+
$ css-slop --restore styles.css # undo: restore the backup
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
What `--fix` rewrites (only patterns the scanner flags — it de-slops, it
|
|
92
|
+
doesn't redesign):
|
|
93
|
+
|
|
94
|
+
| rule | automatic rewrite |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `big-radius` | clamp `border-radius` ≥ 20px to `12px` |
|
|
97
|
+
| `glass` | remove `backdrop-filter: blur(...)` |
|
|
98
|
+
| `purple-gradient` | swap purple/violet stops for neutral slate |
|
|
99
|
+
| `glow-shadow` | replace neon glow with a soft `0 4px 6px rgba(0,0,0,0.1)` shadow |
|
|
100
|
+
| `pure-black` | lift `#000` surfaces to soft `#1e1e1e` |
|
|
101
|
+
| `hero-center` | shrink giant centered titles to `32px`, left-align |
|
|
102
|
+
| `no-light-mode` | append a `prefers-color-scheme: light` hook block |
|
|
103
|
+
|
|
104
|
+
## Rules
|
|
105
|
+
|
|
106
|
+
| rule | what it flags |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `big-radius` | `border-radius` ≥ 20px |
|
|
109
|
+
| `glass` | `backdrop-filter: blur` |
|
|
110
|
+
| `purple-gradient` | purple/violet/indigo/fuchsia gradients |
|
|
111
|
+
| `glow-shadow` | colored `box-shadow` with blur ≥ 24px |
|
|
112
|
+
| `pure-black` | `#000`-style backgrounds |
|
|
113
|
+
| `hero-center` | `text-align: center` + `font-size` ≥ 40px in one rule |
|
|
114
|
+
| `no-light-mode` | dark-only design, no light-mode toggle found |
|
|
115
|
+
|
|
116
|
+
Scores: 0–20 clean, 21–50 mild slop, 51–80 slop, 81–100 peak slop.
|
|
117
|
+
|
|
118
|
+
## Differentiation
|
|
119
|
+
|
|
120
|
+
**Generation side vs. detection+fix side.** Tools like
|
|
121
|
+
[pbakaus/impeccable](https://github.com/pbakaus/impeccable) (and design-skill
|
|
122
|
+
collections like ui-skills or hallmark) teach AI to *generate* better design —
|
|
123
|
+
they improve the prompt. This does the opposite: it scores the *finished
|
|
124
|
+
product's* AI fingerprints, then **closes the loop with one command** —
|
|
125
|
+
`--fix` rewrites the flagged patterns for you, with a diff preview, automatic
|
|
126
|
+
backups, and `--restore` to undo. Detect + fix, not just detect.
|
|
127
|
+
|
|
128
|
+
One file, no dependencies, runs in CI.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
css_slop.py,sha256=6BRXJyif0SVi-cnKtwpX7qfH4Xohzfb6zvrfIPccUz8,21964
|
|
2
|
+
css_slop_detector-0.2.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
|
|
3
|
+
css_slop_detector-0.2.0.dist-info/METADATA,sha256=Kml_mzU9neU19mn05aDAofYd7sIk2DX2EftO5rfxZTA,4541
|
|
4
|
+
css_slop_detector-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
css_slop_detector-0.2.0.dist-info/entry_points.txt,sha256=Wv_HTOP1dPIeJNfArMZbpFM4rMypnDsAtcx3T3y6xX4,43
|
|
6
|
+
css_slop_detector-0.2.0.dist-info/top_level.txt,sha256=K30ntcnqzEHsYnxdw6fI7Q88onJFX_gX6zkrtxel_ak,9
|
|
7
|
+
css_slop_detector-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
css_slop
|