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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ css-slop = css_slop:main
@@ -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