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 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
+ [![npm version](https://img.shields.io/npm/v/ui-critic)](https://www.npmjs.com/package/ui-critic)
6
+ [![license](https://img.shields.io/npm/l/ui-critic)](./LICENSE)
7
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](./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);