ui-critic 0.3.0
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/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +33 -0
- package/skills/design-qa/scripts/scan.mjs +483 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# ui-critic
|
|
2
|
+
|
|
3
|
+
**Design review that doesn't touch your code.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/ui-critic)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](./CONTRIBUTING.md)
|
|
8
|
+
|
|
9
|
+
Your code has lint, tests, and type-checking. Your UI has nothing. `ui-critic` is the missing layer — a structured design review that scores your interface, detects AI-generated slop, and tells you what to fix before you ship.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Two tools in one
|
|
14
|
+
|
|
15
|
+
### 1. CLI scanner (no LLM, zero cost)
|
|
16
|
+
|
|
17
|
+
A standalone regex-based scanner that catches 14 of the 31 slop patterns in milliseconds. No API key, no model, just Node.js.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx ui-critic ./src --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Finds gradient text, pure black/white, missing focus states, placeholder-as-label, and 10 more patterns. Outputs structured findings. The 17 patterns that need DOM/LLM analysis are listed as `needs-llm`.
|
|
24
|
+
|
|
25
|
+
### 2. LLM-powered critique (the skill)
|
|
26
|
+
|
|
27
|
+
Install the Claude Code skill for full design review:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
npx skills add https://github.com/alex410000/ui-critic
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then in Claude Code / Codex / Cursor:
|
|
34
|
+
|
|
35
|
+
| Command | What you get |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `critique [target]` | Full review: Nielsen 10-point score + 31-pattern slop scan + priority-ranked fixes |
|
|
38
|
+
| `score [target]` | Quick Nielsen score. 30-second temperature check. |
|
|
39
|
+
| `slop-check [target]` | AI slop detection only. Does this look AI-generated? |
|
|
40
|
+
| `audit [target]` | Technical quality: accessibility, performance, semantic HTML |
|
|
41
|
+
| `compare [before] [after]` | Before/after diff with scoring delta |
|
|
42
|
+
| `teach` | Initialize PRODUCT.md for your project. Every future review uses it as context. |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## How it compares to real alternatives
|
|
47
|
+
|
|
48
|
+
| | ui-critic | frontend-design-audit | refactoring-ui-plugin | designer-skills |
|
|
49
|
+
|---|---|---|---|---|
|
|
50
|
+
| **Role** | Reviewer only | Reviewer + fixer | Builder + reviewer | Builder + reviewer |
|
|
51
|
+
| **Touches code?** | Never | Yes (suggests fixes) | Yes | Yes |
|
|
52
|
+
| **Nielsen 10-point scoring** | ✅ Full rubric | ✅ 15 heuristics (custom) | ❌ | ❌ |
|
|
53
|
+
| **AI slop detection** | ✅ 31 patterns | ❌ | ❌ | ❌ |
|
|
54
|
+
| **CLI scanner (no LLM)** | ✅ 14 patterns | ❌ | ❌ | ❌ |
|
|
55
|
+
| **PRODUCT.md context** | ✅ | ❌ | ❌ | ❌ |
|
|
56
|
+
| **Skill count / scope** | 1 focused skill | 1 focused skill | 10 skills | 91 skills |
|
|
57
|
+
| **Best for** | "Can this ship?" | "Fix my UI" | "Build it from scratch" | "Full design workflow" |
|
|
58
|
+
|
|
59
|
+
**These don't compete. They're different tools for different moments:**
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
refactoring-ui / designer-skills → BUILD → "Write good-looking code"
|
|
63
|
+
ui-critic → REVIEW → "Is what I wrote actually good?"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install both. Build with one, review with the other.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## What the score means
|
|
71
|
+
|
|
72
|
+
| Score | Rating | Action |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| 36–40 | Excellent | Ship it |
|
|
75
|
+
| 28–35 | Good | Fix weak areas |
|
|
76
|
+
| 20–27 | Acceptable | Significant improvements needed |
|
|
77
|
+
| 12–19 | Poor | Major UX overhaul |
|
|
78
|
+
| 0–11 | Critical | Unusable |
|
|
79
|
+
|
|
80
|
+
Most real interfaces score **20–32**. A 38 is extraordinary. Single-run variance is ±3 points — trends matter more than any individual number.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## What "AI slop" means
|
|
85
|
+
|
|
86
|
+
AI-generated UIs have a fingerprint. Same fonts (Inter, Space Grotesk). Same colors (purple gradient on black). Same layout (centered icon → heading → subtitle → CTA). Same cards. Same everything.
|
|
87
|
+
|
|
88
|
+
We check **31 specific patterns** across 6 categories:
|
|
89
|
+
|
|
90
|
+
| Category | Examples |
|
|
91
|
+
|---|---|
|
|
92
|
+
| Color & Visual | Gradient text, pure black/white, glassmorphism |
|
|
93
|
+
| Layout | Centered-stack hero, identical cards, nested cards |
|
|
94
|
+
| Typography | Inter default, flat type scale, single font |
|
|
95
|
+
| Borders | Side-stripe borders, default focus rings |
|
|
96
|
+
| Components | Missing states, placeholder-as-label, modal first |
|
|
97
|
+
| Content | Em dashes, lorem ipsum |
|
|
98
|
+
|
|
99
|
+
Each pattern has a precise detection criterion (`HIT if...`). No subjective "vibe check."
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## CI integration
|
|
104
|
+
|
|
105
|
+
Run the scanner in CI to catch design regressions before they ship:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
# .github/workflows/design-lint.yml
|
|
109
|
+
name: Design Lint
|
|
110
|
+
on: [pull_request]
|
|
111
|
+
jobs:
|
|
112
|
+
lint:
|
|
113
|
+
runs-on: ubuntu-latest
|
|
114
|
+
steps:
|
|
115
|
+
- uses: actions/checkout@v4
|
|
116
|
+
- uses: actions/setup-node@v4
|
|
117
|
+
with:
|
|
118
|
+
node-version: 20
|
|
119
|
+
- run: npx ui-critic ./src --json > design-report.json
|
|
120
|
+
- name: Check for critical issues
|
|
121
|
+
run: |
|
|
122
|
+
CRITICAL=$(jq '[.findings[] | select(.severity=="P0")] | length' design-report.json)
|
|
123
|
+
if [ "$CRITICAL" -gt 0 ]; then
|
|
124
|
+
echo "::error::Found $CRITICAL critical design issues"
|
|
125
|
+
exit 1
|
|
126
|
+
fi
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
If the scanner flags P0 issues, the PR is blocked. No human review needed for mechanical checks.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Where the standards come from
|
|
134
|
+
|
|
135
|
+
Every scoring criterion has a traceable source:
|
|
136
|
+
|
|
137
|
+
| Standard | Source |
|
|
138
|
+
|---|---|
|
|
139
|
+
| Nielsen's 10 Heuristics | Jakob Nielsen (1994), peer-reviewed, taught in every HCI program |
|
|
140
|
+
| 31 AI slop patterns | Community-observed (impeccable, hallmark, X/Twitter design discourse) |
|
|
141
|
+
| Reflex-reject font list | Impeccable v3.1.1 (Apache 2.0), training-data frequency analysis |
|
|
142
|
+
| Brand vs Product register | Impeccable v3.1.1, adapted for Claude Code |
|
|
143
|
+
| Cognitive load checklist | Sweller's Cognitive Load Theory + Nielsen's recognition-over-recall |
|
|
144
|
+
|
|
145
|
+
**You can disagree with any rule.** The checklist is a starting point. If your brand intentionally uses Inter, mark pattern #13 as "accepted deviation" and move on. The tool serves you.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Why it never touches your code
|
|
150
|
+
|
|
151
|
+
Deliberate design choice:
|
|
152
|
+
|
|
153
|
+
- **Objectivity.** A tool that builds AND reviews its own work can't be trusted to review honestly.
|
|
154
|
+
- **Separation of concerns.** Lint flags problems; you fix them. Same model.
|
|
155
|
+
- **Model independence.** The same critique report can be handed to any LLM (or human).
|
|
156
|
+
|
|
157
|
+
Want the fix implemented? Copy the priority issue into the same conversation and say "fix these." Review and fix are two separate steps by design.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Model compatibility
|
|
162
|
+
|
|
163
|
+
Works with any LLM. Model choice affects accuracy by ±1–3 points, not fundamental conclusions.
|
|
164
|
+
|
|
165
|
+
- **Best aesthetic judgment:** Claude Opus or Sonnet
|
|
166
|
+
- **Slop detection + Nielsen scoring:** DeepSeek, GPT-4o — perfectly fine
|
|
167
|
+
- **CLI scanner:** Model-agnostic. Pure regex. Runs anywhere Node.js runs.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Project structure
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
skills/ui-critic/
|
|
175
|
+
├── SKILL.md # Core: role, commands, scoring framework
|
|
176
|
+
├── eval/
|
|
177
|
+
│ └── test-cases.md # Regression test cases
|
|
178
|
+
├── reference/
|
|
179
|
+
│ ├── heuristics-scoring.md # Nielsen 10-point rubric
|
|
180
|
+
│ ├── slop-patterns.md # 31 AI slop detection points (canonical)
|
|
181
|
+
│ ├── register.md # Brand vs Product scoring
|
|
182
|
+
│ ├── cognitive-load.md # 8-item cognitive load checklist
|
|
183
|
+
│ ├── design-laws.md # Color, typography, layout, motion baseline
|
|
184
|
+
│ └── teach.md # PRODUCT.md initialization flow
|
|
185
|
+
└── scripts/
|
|
186
|
+
└── scan.mjs # Deterministic regex scanner (no LLM)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ui-critic",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Deterministic design lint for frontend — catch AI slop, hardcoded styles, and accessibility issues before you ship. No LLM required.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"design-lint",
|
|
7
|
+
"ui-review",
|
|
8
|
+
"design-qa",
|
|
9
|
+
"ai-slop",
|
|
10
|
+
"nielsen-heuristics",
|
|
11
|
+
"accessibility",
|
|
12
|
+
"css-lint",
|
|
13
|
+
"cli",
|
|
14
|
+
"claude-code",
|
|
15
|
+
"codex",
|
|
16
|
+
"cursor"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/alex410000/ui-critic#readme",
|
|
19
|
+
"bugs": "https://github.com/alex410000/ui-critic/issues",
|
|
20
|
+
"repository": "alex410000/ui-critic",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "alex410000",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"ui-critic": "./skills/design-qa/scripts/scan.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"skills/design-qa/scripts/scan.mjs"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ui-critic scan — Deterministic slop-pattern scanner.
|
|
4
|
+
*
|
|
5
|
+
* Reads HTML/CSS/JSX/TSX/Vue/Svelte/Astro files and flags 31 known
|
|
6
|
+
* AI-slop patterns using regex. Patterns that require DOM or AST
|
|
7
|
+
* analysis are listed separately as "needs-llm".
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scan.mjs <file-or-dir> [--json] [--fast]
|
|
11
|
+
*
|
|
12
|
+
* --json Output machine-readable JSON
|
|
13
|
+
* --fast Skip jsdom-dependent checks (regex only)
|
|
14
|
+
*
|
|
15
|
+
* Exit code: 0 = clean, 1 = findings
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, statSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join, extname, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// ── Collect target files ──────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function walk(dir, exts) {
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const entry of readdirSync(dir)) {
|
|
26
|
+
const full = join(dir, entry);
|
|
27
|
+
const s = statSync(full);
|
|
28
|
+
if (s.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') {
|
|
29
|
+
results.push(...walk(full, exts));
|
|
30
|
+
} else if (s.isFile() && exts.has(extname(entry).toLowerCase())) {
|
|
31
|
+
results.push(full);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Only UI files — .js/.ts are excluded because they rarely contain
|
|
38
|
+
// visual output directly. Patterns like Zero Imagery and Default Focus
|
|
39
|
+
// Rings are meaningless on backend logic files.
|
|
40
|
+
const SCAN_EXTS = new Set([
|
|
41
|
+
'.html', '.htm', '.css',
|
|
42
|
+
'.jsx', '.tsx', '.vue', '.svelte', '.astro',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function collect(targets) {
|
|
46
|
+
const files = [];
|
|
47
|
+
for (const t of targets) {
|
|
48
|
+
const abs = resolve(t);
|
|
49
|
+
try {
|
|
50
|
+
const s = statSync(abs);
|
|
51
|
+
if (s.isDirectory()) files.push(...walk(abs, SCAN_EXTS));
|
|
52
|
+
else if (s.isFile()) files.push(abs);
|
|
53
|
+
} catch { /* skip missing */ }
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Pattern definitions ───────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Each pattern:
|
|
62
|
+
* id: canonical number (matches slop-patterns.md)
|
|
63
|
+
* name: short label
|
|
64
|
+
* method: "regex" | "ast"
|
|
65
|
+
* test(content): returns { hit: true, evidence: [...] } | { hit: false }
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
const PATTERNS = [
|
|
69
|
+
// ── Category A: Color & Visual (regex-able: 5/5) ──
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
id: 1, name: 'Gradient Text', method: 'regex',
|
|
73
|
+
test(c) {
|
|
74
|
+
const hasClip = /background(?:-clip)?\s*:\s*text/.test(c);
|
|
75
|
+
const hasGrad = /background(?:-image)?\s*:\s*linear-gradient\s*\(/.test(c);
|
|
76
|
+
if (!hasClip || !hasGrad) return { hit: false };
|
|
77
|
+
const matches = c.match(/background(?:-clip)?\s*:\s*text/g);
|
|
78
|
+
return { hit: true, evidence: matches.slice(0, 3) };
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
id: 2, name: 'Pure Black or White', method: 'regex',
|
|
84
|
+
test(c) {
|
|
85
|
+
const matches = c.match(/#000\b|#fff\b|#000000\b|#ffffff\b/gi);
|
|
86
|
+
if (!matches) return { hit: false };
|
|
87
|
+
const filtered = matches.filter(m => {
|
|
88
|
+
const ctx = c.substring(
|
|
89
|
+
Math.max(0, c.indexOf(m) - 30),
|
|
90
|
+
c.indexOf(m) + m.length + 10
|
|
91
|
+
);
|
|
92
|
+
return /color|background|fill|stroke|border/.test(ctx);
|
|
93
|
+
});
|
|
94
|
+
return filtered.length > 0
|
|
95
|
+
? { hit: true, evidence: [...new Set(filtered)].slice(0, 5) }
|
|
96
|
+
: { hit: false };
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
id: 3, name: 'Glassmorphism as Default', method: 'regex',
|
|
102
|
+
test(c) {
|
|
103
|
+
const matches = c.match(/backdrop-filter\s*:\s*blur\s*\(/g);
|
|
104
|
+
if (!matches) return { hit: false };
|
|
105
|
+
return matches.length >= 2
|
|
106
|
+
? { hit: true, evidence: [`${matches.length} occurrences of backdrop-filter: blur()`] }
|
|
107
|
+
: { hit: false };
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
id: 4, name: 'Heavy Color on Inactive States', method: 'ast',
|
|
113
|
+
test() { return { hit: false }; }
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
id: 5, name: 'Timid Palette (Brand Register)', method: 'ast',
|
|
118
|
+
test() { return { hit: false }; }
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// ── Category B: Layout & Composition ──
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
id: 6, name: 'Centered-Stack Hero', method: 'ast',
|
|
125
|
+
test() { return { hit: false }; }
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
id: 7, name: 'Identical Card Grids', method: 'ast',
|
|
130
|
+
test() { return { hit: false }; }
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
id: 8, name: 'Hero-Metric Template', method: 'ast',
|
|
135
|
+
test() { return { hit: false }; }
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
id: 9, name: 'Nested Cards', method: 'ast',
|
|
140
|
+
test() { return { hit: false }; }
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
id: 10, name: 'Over-Containerization', method: 'ast',
|
|
145
|
+
test() { return { hit: false }; }
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
id: 11, name: 'Monotonous Spacing', method: 'ast',
|
|
150
|
+
test() { return { hit: false }; }
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
id: 12, name: 'Zero Imagery', method: 'regex',
|
|
155
|
+
test(c) {
|
|
156
|
+
// Only meaningful in markup files — skip pure CSS/JS.
|
|
157
|
+
// Check for closing tags (</div>) which never appear in CSS.
|
|
158
|
+
if (!/<\/[a-zA-Z]+/.test(c)) return { hit: false };
|
|
159
|
+
const hasImg = /<img\s/.test(c) || /<svg\s/.test(c) || /<picture\s/.test(c);
|
|
160
|
+
return { hit: !hasImg, evidence: !hasImg ? ['No <img>, <svg>, or <picture> tags found'] : [] };
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// ── Category C: Typography ──
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
id: 13, name: 'Inter as Unconsidered Default', method: 'regex',
|
|
168
|
+
test(c) {
|
|
169
|
+
const hasInter = /font-family\s*:\s*[^;]*\bInter\b/.test(c);
|
|
170
|
+
if (!hasInter) return { hit: false };
|
|
171
|
+
// Count distinct font-family values to check for typographic system
|
|
172
|
+
const families = c.match(/font-family\s*:\s*([^;}]+)/g) || [];
|
|
173
|
+
const uniqueFamilies = new Set(families.map(f => f.toLowerCase()));
|
|
174
|
+
return uniqueFamilies.size <= 2
|
|
175
|
+
? { hit: true, evidence: [`Inter found with only ${uniqueFamilies.size} font-family declaration(s) — no typographic system visible`] }
|
|
176
|
+
: { hit: false };
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
id: 14, name: 'Reflex-Reject Fonts', method: 'regex',
|
|
182
|
+
test(c) {
|
|
183
|
+
const banned = [
|
|
184
|
+
'Fraunces', 'Newsreader', 'Lora', 'Crimson', 'Playfair Display',
|
|
185
|
+
'Cormorant', 'Syne', 'IBM Plex Mono', 'IBM Plex Sans', 'IBM Plex Serif',
|
|
186
|
+
'Space Mono', 'Space Grotesk', 'DM Sans', 'DM Serif Display',
|
|
187
|
+
'DM Serif Text', 'Outfit', 'Plus Jakarta Sans', 'Instrument Sans',
|
|
188
|
+
'Instrument Serif',
|
|
189
|
+
];
|
|
190
|
+
const hits = [];
|
|
191
|
+
for (const font of banned) {
|
|
192
|
+
const re = new RegExp(`font-family\\s*:\\s*[^;]*\\b${font.replace(/ /g, '\\s')}\\b`, 'i');
|
|
193
|
+
if (re.test(c)) hits.push(font);
|
|
194
|
+
}
|
|
195
|
+
return hits.length > 0
|
|
196
|
+
? { hit: true, evidence: hits }
|
|
197
|
+
: { hit: false };
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
id: 15, name: 'All-Caps Body Copy', method: 'regex',
|
|
203
|
+
test(c) {
|
|
204
|
+
// Strip <style> and <script> blocks to avoid false positives
|
|
205
|
+
// on CSS section headers and JS constant names
|
|
206
|
+
const text = c.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
207
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
208
|
+
const capsWords = text.match(/\b[A-Z]{8,}\b/g);
|
|
209
|
+
return capsWords && capsWords.length >= 3
|
|
210
|
+
? { hit: true, evidence: capsWords.slice(0, 5) }
|
|
211
|
+
: { hit: false };
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
id: 16, name: 'Display Fonts in Functional UI', method: 'ast',
|
|
217
|
+
test() { return { hit: false }; }
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
id: 17, name: 'Flat Type Scale', method: 'ast',
|
|
222
|
+
test() { return { hit: false }; }
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
id: 18, name: 'Single-Family Without Choice', method: 'ast',
|
|
227
|
+
test() { return { hit: false }; }
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
// ── Category D: Borders & Decorations ──
|
|
231
|
+
|
|
232
|
+
{
|
|
233
|
+
id: 19, name: 'Side-Stripe Borders', method: 'regex',
|
|
234
|
+
test(c) {
|
|
235
|
+
const matches = c.match(/border-(?:left|right)\s*:\s*(\d+)px\s+solid/gi);
|
|
236
|
+
if (!matches) return { hit: false };
|
|
237
|
+
const thick = matches.filter(m => {
|
|
238
|
+
const px = parseInt(m.match(/(\d+)px/)?.[1] || '0');
|
|
239
|
+
return px > 1;
|
|
240
|
+
});
|
|
241
|
+
return thick.length > 0
|
|
242
|
+
? { hit: true, evidence: thick.slice(0, 3) }
|
|
243
|
+
: { hit: false };
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
{
|
|
248
|
+
id: 20, name: 'Large Rounded-Corner Icons Above Headings', method: 'ast',
|
|
249
|
+
test() { return { hit: false }; }
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
{
|
|
253
|
+
id: 21, name: 'Default Focus Rings', method: 'regex',
|
|
254
|
+
test(c) {
|
|
255
|
+
const hasFocus = /:focus-visible/.test(c);
|
|
256
|
+
if (!hasFocus) return { hit: true, evidence: ['No :focus-visible rule found — likely using browser defaults'] };
|
|
257
|
+
// Check if the focus style is just the default outline reset
|
|
258
|
+
const focusRules = c.match(/:focus-visible\s*\{[^}]*\}/g) || [];
|
|
259
|
+
const styled = focusRules.filter(r => r.length > 30); // nontrivial rule body
|
|
260
|
+
return styled.length === 0
|
|
261
|
+
? { hit: true, evidence: [':focus-visible present but appears un-customized'] }
|
|
262
|
+
: { hit: false };
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
{
|
|
267
|
+
id: 22, name: 'Z-Index Chaos', method: 'regex',
|
|
268
|
+
test(c) {
|
|
269
|
+
const zs = c.match(/z-index\s*:\s*(\d+)/g) || [];
|
|
270
|
+
const values = zs.map(z => parseInt(z.match(/(\d+)/)[1]));
|
|
271
|
+
const high = values.filter(v => v >= 100);
|
|
272
|
+
return values.length >= 10 && high.length >= 3
|
|
273
|
+
? { hit: true, evidence: [`${values.length} z-index declarations, ${high.length} values ≥ 100 — high stacking-context complexity`] }
|
|
274
|
+
: { hit: false };
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// ── Category E: Components & Interaction ──
|
|
279
|
+
|
|
280
|
+
{
|
|
281
|
+
id: 23, name: 'Modal as First Thought', method: 'ast',
|
|
282
|
+
test() { return { hit: false }; }
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
{
|
|
286
|
+
id: 24, name: 'Inconsistent Component Vocabulary', method: 'ast',
|
|
287
|
+
test() { return { hit: false }; }
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
id: 25, name: 'Missing Interactive States', method: 'regex',
|
|
292
|
+
test(c) {
|
|
293
|
+
const hasBtn = /<button\s|<a\s.*class="[^"]*btn/.test(c);
|
|
294
|
+
if (!hasBtn) return { hit: false };
|
|
295
|
+
// Check all 4 core interactive states — need ≥2 missing to trigger
|
|
296
|
+
const hasHover = /:hover/.test(c);
|
|
297
|
+
const hasFocus = /:focus/.test(c);
|
|
298
|
+
const hasActive = /:active/.test(c);
|
|
299
|
+
const hasDisabled = /:disabled/.test(c);
|
|
300
|
+
const missing = [];
|
|
301
|
+
if (!hasHover) missing.push(':hover');
|
|
302
|
+
if (!hasFocus) missing.push(':focus/:focus-visible');
|
|
303
|
+
if (!hasActive) missing.push(':active');
|
|
304
|
+
if (!hasDisabled) missing.push(':disabled');
|
|
305
|
+
return missing.length >= 2
|
|
306
|
+
? { hit: true, evidence: [`${missing.length}/4 interactive states missing: ${missing.join(', ')}`] }
|
|
307
|
+
: { hit: false };
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
{
|
|
312
|
+
id: 26, name: 'Non-Standard Affordances', method: 'ast',
|
|
313
|
+
test() { return { hit: false }; }
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
{
|
|
317
|
+
id: 27, name: 'Placeholder as Label', method: 'regex',
|
|
318
|
+
test(c) {
|
|
319
|
+
const places = c.match(/placeholder\s*=\s*["'][^"']+["']/g) || [];
|
|
320
|
+
const labels = (c.match(/<label\s/gi) || []).length;
|
|
321
|
+
return places.length > labels.length
|
|
322
|
+
? { hit: true, evidence: [`${places.length} placeholders but only ${labels} <label> elements`] }
|
|
323
|
+
: { hit: false };
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
{
|
|
328
|
+
id: 28, name: 'Icon-Only Interactive Elements', method: 'ast',
|
|
329
|
+
test() { return { hit: false }; }
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
{
|
|
333
|
+
id: 29, name: 'Decorative Motion That Blocks Tasks', method: 'ast',
|
|
334
|
+
test() { return { hit: false }; }
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
// ── Category F: Content & Copy ──
|
|
338
|
+
|
|
339
|
+
{
|
|
340
|
+
id: 30, name: 'Em Dashes in UI Copy', method: 'regex',
|
|
341
|
+
test(c) {
|
|
342
|
+
// Strip <style> and <script> blocks: CSS custom properties (--foo)
|
|
343
|
+
// and JS operators (--, --i) are not UI copy. Also strip HTML comments.
|
|
344
|
+
let text = c.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
345
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
346
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
347
|
+
// For standalone .css files (no <style> wrapper), strip CSS comments
|
|
348
|
+
// and CSS custom property names which use -- prefix by spec.
|
|
349
|
+
text = text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
350
|
+
text = text.replace(/--[a-zA-Z][\w-]*/g, '');
|
|
351
|
+
// Count actual em dash (U+2014) and double-hyphens used in prose
|
|
352
|
+
const emDashCount = (text.match(/—/g) || []).length;
|
|
353
|
+
const doubleHyphenCount = (text.match(/--/g) || []).length;
|
|
354
|
+
const total = emDashCount + doubleHyphenCount;
|
|
355
|
+
return total >= 3
|
|
356
|
+
? { hit: true, evidence: [`${total} em dashes or double-hyphens in content (CSS/JS excluded)`] }
|
|
357
|
+
: { hit: false };
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
{
|
|
362
|
+
id: 31, name: 'Lorem Ipsum / Placeholder Copy', method: 'regex',
|
|
363
|
+
test(c) {
|
|
364
|
+
const matches = c.match(/lorem ipsum|TODO|\[placeholder\]|content goes here/gi);
|
|
365
|
+
return matches
|
|
366
|
+
? { hit: true, evidence: [...new Set(matches)] }
|
|
367
|
+
: { hit: false };
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
// ── Scanner ────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
function scanFile(filePath) {
|
|
375
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
376
|
+
const findings = [];
|
|
377
|
+
const llmOnly = [];
|
|
378
|
+
|
|
379
|
+
for (const p of PATTERNS) {
|
|
380
|
+
if (p.method === 'ast') {
|
|
381
|
+
llmOnly.push({ id: p.id, name: p.name });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const result = p.test(content);
|
|
386
|
+
if (result.hit) {
|
|
387
|
+
findings.push({ id: p.id, name: p.name, method: 'regex', evidence: result.evidence });
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
// skip broken patterns silently
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { file: filePath, findings, llmOnly };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function scanAll(files) {
|
|
398
|
+
const results = [];
|
|
399
|
+
let totalFindings = 0;
|
|
400
|
+
const llmOnlySet = new Map(); // dedupe by id
|
|
401
|
+
|
|
402
|
+
for (const f of files) {
|
|
403
|
+
const r = scanFile(f);
|
|
404
|
+
results.push(r);
|
|
405
|
+
totalFindings += r.findings.length;
|
|
406
|
+
// Aggregate LLM-only patterns across all files (deduplicated)
|
|
407
|
+
for (const p of r.llmOnly) {
|
|
408
|
+
if (!llmOnlySet.has(p.id)) llmOnlySet.set(p.id, p);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
results,
|
|
414
|
+
totalFindings,
|
|
415
|
+
filesScanned: files.length,
|
|
416
|
+
llmOnly: [...llmOnlySet.values()].sort((a, b) => a.id - b.id),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Formatters ─────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
function formatText(report) {
|
|
423
|
+
const lines = [];
|
|
424
|
+
lines.push('');
|
|
425
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
426
|
+
lines.push(' ui-critic scan');
|
|
427
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
428
|
+
lines.push(` Files scanned: ${report.filesScanned}`);
|
|
429
|
+
lines.push(` Regex hits: ${report.totalFindings}`);
|
|
430
|
+
lines.push(` AST-only patterns (needs LLM): ${report.llmOnly?.length || 0}`);
|
|
431
|
+
lines.push('');
|
|
432
|
+
|
|
433
|
+
for (const r of report.results) {
|
|
434
|
+
if (r.findings.length === 0) continue;
|
|
435
|
+
lines.push(` ${r.file}`);
|
|
436
|
+
for (const f of r.findings) {
|
|
437
|
+
const evidence = f.evidence?.join(', ') || '';
|
|
438
|
+
lines.push(` #${f.id} ${f.name}`);
|
|
439
|
+
if (evidence) lines.push(` → ${evidence}`);
|
|
440
|
+
}
|
|
441
|
+
lines.push('');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (report.totalFindings === 0) {
|
|
445
|
+
lines.push(' ✓ No regex-detectable slop patterns found.');
|
|
446
|
+
lines.push(' ℹ This covers ~16 of 31 patterns. Run a full LLM critique');
|
|
447
|
+
lines.push(' for the remaining 15 AST-only patterns.');
|
|
448
|
+
} else {
|
|
449
|
+
lines.push(` ⚠ ${report.totalFindings} finding(s). Run a full LLM critique with`);
|
|
450
|
+
lines.push(' /ui-critic critique for Nielsen scoring + AST patterns.');
|
|
451
|
+
}
|
|
452
|
+
lines.push('');
|
|
453
|
+
|
|
454
|
+
return lines.join('\n');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Main ───────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
const args = process.argv.slice(2);
|
|
460
|
+
const jsonOut = args.includes('--json');
|
|
461
|
+
const targets = args.filter(a => !a.startsWith('--'));
|
|
462
|
+
|
|
463
|
+
if (targets.length === 0) {
|
|
464
|
+
console.error('Usage: node scan.mjs <file-or-dir> [--json]');
|
|
465
|
+
console.error(' Scans HTML/CSS/JSX/TSX/Vue/Svelte/Astro files for AI slop patterns.');
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const files = collect(targets);
|
|
470
|
+
if (files.length === 0) {
|
|
471
|
+
console.error('No scannable files found.');
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const report = scanAll(files);
|
|
476
|
+
|
|
477
|
+
if (jsonOut) {
|
|
478
|
+
console.log(JSON.stringify(report, null, 2));
|
|
479
|
+
} else {
|
|
480
|
+
console.log(formatText(report));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
process.exit(report.totalFindings > 0 ? 2 : 0);
|