qualia-framework 4.4.0 → 4.5.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/CLAUDE.md +13 -1
- package/README.md +1 -2
- package/agents/builder.md +12 -20
- package/agents/plan-checker.md +18 -0
- package/agents/planner.md +9 -0
- package/agents/verifier.md +62 -0
- package/bin/cli.js +10 -5
- package/bin/install.js +6 -0
- package/bin/slop-detect.mjs +357 -0
- package/bin/state.js +184 -1
- package/docs/erp-contract.md +5 -0
- package/package.json +1 -1
- package/rules/design-brand.md +110 -0
- package/rules/design-laws.md +144 -0
- package/rules/design-product.md +110 -0
- package/rules/design-rubric.md +153 -0
- package/skills/qualia-new/SKILL.md +40 -3
- package/skills/qualia-polish/SKILL.md +180 -136
- package/skills/qualia-report/SKILL.md +24 -4
- package/skills/zoho-workflow/SKILL.md +64 -0
- package/templates/DESIGN.md +229 -435
- package/templates/PRODUCT.md +95 -0
- package/skills/qualia-design/SKILL.md +0 -169
package/CLAUDE.md
CHANGED
|
@@ -36,8 +36,20 @@ For each milestone, for each phase:
|
|
|
36
36
|
↓
|
|
37
37
|
/qualia-milestone → close milestone, archive artifacts, prep next (human gate)
|
|
38
38
|
↓ (repeat for each milestone until Handoff)
|
|
39
|
+
Design as a thread (v4.5.0+): every road agent loads PRODUCT.md +
|
|
40
|
+
DESIGN.md + design-laws.md substrate. Builders run slop-detect on every
|
|
41
|
+
frontend commit. Verifiers score 8 design dimensions per phase.
|
|
42
|
+
|
|
43
|
+
/qualia-polish is now a flexible verb usable at any scope:
|
|
44
|
+
/qualia-polish src/components/Button.tsx ~30s component touch-up
|
|
45
|
+
/qualia-polish app/dashboard ~3m section pass
|
|
46
|
+
/qualia-polish ~12m whole app, fan-out
|
|
47
|
+
/qualia-polish --redesign ~30m ground-up redesign
|
|
48
|
+
/qualia-polish --critique read-only scored audit
|
|
49
|
+
/qualia-polish --quick ~1m gates only
|
|
50
|
+
|
|
39
51
|
Final milestone = Handoff:
|
|
40
|
-
/qualia-polish → design
|
|
52
|
+
/qualia-polish → final design pass (whole app)
|
|
41
53
|
(content + SEO) → Phase 2
|
|
42
54
|
(final QA) → Phase 3
|
|
43
55
|
/qualia-ship → deploy to production (quality gates → deploy → verify)
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Open Claude Code in any project directory.
|
|
|
40
40
|
...repeat plan/build/verify per phase...
|
|
41
41
|
/qualia-milestone # Close current milestone, open next (loads next scope from JOURNEY.md)
|
|
42
42
|
...repeat per milestone until the final "Handoff" milestone...
|
|
43
|
-
/qualia-polish # Design
|
|
43
|
+
/qualia-polish # Design pass — flexible scope: component, route, app, redesign, critique, quick
|
|
44
44
|
/qualia-ship # Deploy to production
|
|
45
45
|
/qualia-handoff # Enforce the 4 mandatory handoff deliverables
|
|
46
46
|
/qualia-report # Mandatory end-of-session report + ERP upload
|
|
@@ -77,7 +77,6 @@ Two human gates per project. One halt case (gap-cycle limit exceeded on a failin
|
|
|
77
77
|
|
|
78
78
|
```
|
|
79
79
|
/qualia-debug # Structured debugging
|
|
80
|
-
/qualia-design # One-shot design transformation
|
|
81
80
|
/qualia-review # Production audit (scored diagnostics)
|
|
82
81
|
/qualia-optimize # Deep optimization pass (parallel specialist agents)
|
|
83
82
|
/qualia-quick # Fast path for trivial fixes (skips planning)
|
package/agents/builder.md
CHANGED
|
@@ -84,10 +84,11 @@ Before committing:
|
|
|
84
84
|
1. Run every command in **Validation:** — they must pass
|
|
85
85
|
2. Mentally walk through each **Acceptance Criterion** — does the code actually produce that observable behavior?
|
|
86
86
|
3. Run `npx tsc --noEmit` if you touched TypeScript files
|
|
87
|
-
4.
|
|
88
|
-
5.
|
|
87
|
+
4. **If you touched any `.tsx/.jsx/.css/.scss/.html` file: run `node bin/slop-detect.mjs {touched paths}`. Exit 1 (critical findings) BLOCKS the commit.** Fix the findings (apply the rewrite recipe in the script's output), re-run, repeat until exit 0.
|
|
88
|
+
5. No `// TODO`, no placeholder text, no stub functions
|
|
89
|
+
6. Imports are wired — not just declared but actually used
|
|
89
90
|
|
|
90
|
-
If any Validation command fails or any AC is not met, fix before committing. Do not commit and hope the verifier catches it.
|
|
91
|
+
If any Validation command fails, slop-detect returns 1, or any AC is not met, fix before committing. Do not commit and hope the verifier catches it.
|
|
91
92
|
|
|
92
93
|
### 5. Commit
|
|
93
94
|
One atomic commit per task:
|
|
@@ -132,23 +133,14 @@ Rule of thumb: If you can explain the change in one sentence in a commit message
|
|
|
132
133
|
- Always check auth server-side
|
|
133
134
|
- Enable RLS on every table
|
|
134
135
|
- Validate input with Zod at system boundaries
|
|
135
|
-
5. **Frontend standards (mandatory for any .tsx/.jsx/.css file):**
|
|
136
|
-
- Before
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
140
|
-
-
|
|
141
|
-
-
|
|
142
|
-
-
|
|
143
|
-
- Semantic HTML (`nav`, `main`, `section`, `article`) — not div soup
|
|
144
|
-
- Keyboard accessible: Tab, Enter, Escape, Arrow keys work
|
|
145
|
-
- Touch targets: 44px minimum
|
|
146
|
-
- Form inputs: visible labels (not placeholder-only), error messages with `aria-describedby`
|
|
147
|
-
- Motion: 150–200ms hover, 250ms expand, stagger children on load, respect `prefers-reduced-motion`
|
|
148
|
-
- Mobile-first responsive: stack on mobile, expand on desktop, fluid typography
|
|
149
|
-
- Skip link on every page, heading hierarchy (one h1, sequential order)
|
|
150
|
-
- No emoji as icons — use SVGs
|
|
151
|
-
- `cursor: pointer` on all clickable elements
|
|
136
|
+
5. **Frontend standards (mandatory for any .tsx/.jsx/.css/.scss/.html file):**
|
|
137
|
+
- **Read substrate first.** Before any frontend code: read `PRODUCT.md`, `DESIGN.md`, `rules/design-laws.md`, AND the matching register file (`rules/design-brand.md` if `register: brand`, `rules/design-product.md` if `register: product`). These ARE the source of truth.
|
|
138
|
+
- **Honor the task's `**Design:**` contract.** If the planner specified `Tokens used: var(--accent), --space-4`, those are the tokens you use — don't introduce new ones without flagging.
|
|
139
|
+
- **OKLCH only.** No `#000`, no `#fff`, no scattered hex. Reference design tokens via `var(--name)`.
|
|
140
|
+
- **Banned fonts:** Inter, Roboto, Arial, Helvetica, system-ui, Space Grotesk. Use the font defined in DESIGN.md §3.
|
|
141
|
+
- **No purple-blue gradients, no gradient text, no side-stripe borders, no glassmorphism by default, no identical card grids, no modal as first thought, no em dashes** (per `rules/design-laws.md` §8 absolute bans).
|
|
142
|
+
- **Pre-commit guard:** run `node bin/slop-detect.mjs {touched files}`. Exit 1 = blocked.
|
|
143
|
+
- All other rules (states, semantics, keyboard, touch targets, motion, responsive, headings, skip links, no-emoji-icons, cursor:pointer, WCAG AA) carry over from `rules/design-laws.md` and the register file.
|
|
152
144
|
6. **No empty catch blocks.** At minimum, log the error.
|
|
153
145
|
7. **No dangerouslySetInnerHTML.** No eval().
|
|
154
146
|
8. **React/Next.js performance:**
|
package/agents/plan-checker.md
CHANGED
|
@@ -105,6 +105,24 @@ If `.planning/phase-{N}-context.md` exists, read its "Locked Decisions" section.
|
|
|
105
105
|
|
|
106
106
|
**FAIL if:** plan contradicts a locked decision (e.g., context says "use library X" but plan uses library Y).
|
|
107
107
|
|
|
108
|
+
### Rule 7b: Frontend tasks have a design contract (v4.5.0+)
|
|
109
|
+
|
|
110
|
+
A "frontend task" is any task whose **Files:** list contains a `.tsx`, `.jsx`, `.css`, `.scss`, `.html`, `.svelte`, `.vue`, or `.astro` path.
|
|
111
|
+
|
|
112
|
+
Every frontend task MUST include a `**Design:**` field with:
|
|
113
|
+
- `Register: brand` or `Register: product`
|
|
114
|
+
- `Tokens used:` non-empty list of CSS custom properties (e.g. `var(--accent), --space-4`) — proves the task references DESIGN.md tokens, not raw hex/px
|
|
115
|
+
- `Scope: component|section|page|app`
|
|
116
|
+
- `Anti-pattern guard:` line confirming builder runs `bin/slop-detect.mjs` pre-commit
|
|
117
|
+
|
|
118
|
+
**FAIL if:**
|
|
119
|
+
- Frontend task missing `**Design:**` field entirely
|
|
120
|
+
- Register is neither `brand` nor `product`
|
|
121
|
+
- Tokens used is empty or contains raw hex (`#ff0000`) instead of CSS-var references
|
|
122
|
+
- Plan steps on absolute bans (per `rules/design-laws.md` §8): grep the plan for `gradient text`, `glassmorphism`, `purple gradient`, `hero metric template`, `identical card grid`, `modal as first thought`, `border-left:.4px` decorative, `font-family: Inter`, `Space Grotesk`. Any hit = REVISE.
|
|
123
|
+
|
|
124
|
+
Non-frontend tasks (backend, migrations, API routes without UI) MUST NOT have a `**Design:**` field. Warn but don't fail if one is mistakenly added.
|
|
125
|
+
|
|
108
126
|
### Rule 8: Validation commands test behavior, not just existence
|
|
109
127
|
|
|
110
128
|
Each task's `**Validation:**` list must contain at least one `grep-match` or `command-exit` check — a command that proves the code DOES something. A task whose ONLY validation is `test -f {file}` will pass even if the file contains only `// TODO`.
|
package/agents/planner.md
CHANGED
|
@@ -11,6 +11,9 @@ You create phase plans. Plans are prompts — they ARE the instructions the buil
|
|
|
11
11
|
## Input
|
|
12
12
|
|
|
13
13
|
- `<project_context>` — inlined `.planning/PROJECT.md` contents
|
|
14
|
+
- `<product_context>` — inlined `PRODUCT.md` (if present — required from v4.5.0 onward; substrate for any frontend task)
|
|
15
|
+
- `<design_spec>` — inlined `DESIGN.md` (if present — visual contract for any frontend task)
|
|
16
|
+
- `<design_substrate>` — inlined `rules/design-laws.md` + matching register file (`rules/design-brand.md` OR `rules/design-product.md` based on PRODUCT.md `register:` field)
|
|
14
17
|
- `<current_state>` — inlined `.planning/STATE.md` contents
|
|
15
18
|
- `<phase_details>` — phase goal + success criteria + REQ-IDs from ROADMAP.md
|
|
16
19
|
- `<locked_decisions>` (optional) — Locked Decisions from `.planning/phase-{N}-context.md` if it exists
|
|
@@ -101,6 +104,12 @@ waves: {count}
|
|
|
101
104
|
|
|
102
105
|
**Context:** Read @{file references}
|
|
103
106
|
|
|
107
|
+
**Design:** (REQUIRED for any task touching .tsx/.jsx/.css/.scss/.html — omit otherwise)
|
|
108
|
+
- Register: {brand|product}
|
|
109
|
+
- Tokens used: {var(--accent), var(--text), --space-4, ...}
|
|
110
|
+
- Scope: {component|section|page|app}
|
|
111
|
+
- Anti-pattern guard: builder runs `node bin/slop-detect.mjs {target}` pre-commit; commit blocked on critical findings
|
|
112
|
+
|
|
104
113
|
## Success Criteria
|
|
105
114
|
- [ ] {phase-level truth 1}
|
|
106
115
|
- [ ] {phase-level truth 2}
|
package/agents/verifier.md
CHANGED
|
@@ -14,6 +14,9 @@ You verify that a phase achieved its GOAL, not just completed its TASKS.
|
|
|
14
14
|
|
|
15
15
|
- `<plan_path>` — path to `.planning/phase-{N}-plan.md`
|
|
16
16
|
- `<project_context>` — inlined `.planning/PROJECT.md` contents (for Quality scoring against project conventions)
|
|
17
|
+
- `<product_context>` — inlined `PRODUCT.md` (if present, v4.5.0+) — register, anti-references, principles
|
|
18
|
+
- `<design_spec>` — inlined `DESIGN.md` (if present) — visual contract for design rubric scoring
|
|
19
|
+
- `<design_substrate>` — inlined `rules/design-laws.md`, `rules/design-rubric.md`, and the matching register file
|
|
17
20
|
- `<previous_verification>` (optional) — inlined `.planning/phase-{N}-verification.md` from a prior run
|
|
18
21
|
|
|
19
22
|
## Output
|
|
@@ -118,6 +121,65 @@ grep -c "async.*=> {}\|() => {}" {file}
|
|
|
118
121
|
|
|
119
122
|
If Level 2 finds more than 2 stub patterns in a single file, mark that criterion as **FAIL** regardless of other checks. Stubs are not implementations.
|
|
120
123
|
|
|
124
|
+
## Design Verification (v4.5.0+)
|
|
125
|
+
|
|
126
|
+
If the phase touched any frontend file (`.tsx/.jsx/.css/.scss/.html`), run the design verification block IN ADDITION to the functional verification above. Design FAIL blocks the phase the same way a functional FAIL does.
|
|
127
|
+
|
|
128
|
+
### Step A — slop-detect gate (must pass)
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
node bin/slop-detect.mjs {touched frontend paths from git diff}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
If exit code is 1 (critical findings present), the phase FAILS. Quote the findings in the report. Do not score the rubric — fix slop first.
|
|
135
|
+
|
|
136
|
+
### Step B — Design rubric scoring (8 dimensions)
|
|
137
|
+
|
|
138
|
+
Apply `rules/design-rubric.md`. Score 1-5 per dimension WITH evidence on the next line. Default to 3 unless evidence supports otherwise.
|
|
139
|
+
|
|
140
|
+
Scoped by phase scope:
|
|
141
|
+
- Component-only phase → score Typography, Color cohesion, States, Motion intent, Microcopy, Container depth (skip Layout originality, Spatial rhythm — those are page-level concerns)
|
|
142
|
+
- Page/section phase → all 8 dimensions
|
|
143
|
+
- Full app phase → all 8 dimensions across 2-3 representative routes, average
|
|
144
|
+
|
|
145
|
+
Output format (mandatory, append to verification.md):
|
|
146
|
+
|
|
147
|
+
```markdown
|
|
148
|
+
## Design Rubric — Phase {N}
|
|
149
|
+
|
|
150
|
+
| Dim | Score | Evidence |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| Typography | 4 | `app/page.tsx:14` Fraunces + JetBrains Mono pair, weights 400/500/700 |
|
|
153
|
+
| Color cohesion | 3 | All CSS vars in `app/globals.css:8-22`, OKLCH used, strategy: Restrained |
|
|
154
|
+
| ... | ... | ... |
|
|
155
|
+
|
|
156
|
+
**Aggregate:** {sum}/40 (avg {sum/8})
|
|
157
|
+
**Design verdict:** PASS (all dims ≥ 3) | FAIL (Layout Originality at 2 — three-column grid, see `app/page.tsx:42`)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Step C — Drift audit (full app verification only)
|
|
161
|
+
|
|
162
|
+
Compare implementation against DESIGN.md tokens. Flag tokens used in code but not declared, and raw hex values still appearing.
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# Orphan tokens (used in code, missing from DESIGN.md)
|
|
166
|
+
grep -rE "var\(--[a-z-]+\)" src/ app/ components/ 2>/dev/null | \
|
|
167
|
+
awk -F'var\\(--' '{print $2}' | awk -F'\\)' '{print $1}' | sort -u > /tmp/used-tokens
|
|
168
|
+
grep -E "^\s*--[a-z-]+:" DESIGN.md 2>/dev/null | sed -E 's/.*--([a-z-]+):.*/\1/' | sort -u > /tmp/declared
|
|
169
|
+
comm -23 /tmp/used-tokens /tmp/declared
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Drift findings are reported, not auto-failing. Drift may be intentional. But if 5+ orphan tokens appear, flag as MEDIUM finding for the next polish cycle.
|
|
173
|
+
|
|
174
|
+
### Phase verdict (combined)
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
phase_pass = functional_pass AND slop_detect_pass AND design_rubric_pass
|
|
178
|
+
phase_fail = ANY of the above failed
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A perfect functional verification with a Design Rubric score of 2 in any dimension is a phase FAIL. Design is not a "would be nice" — it's a verification dimension equal to functionality.
|
|
182
|
+
|
|
121
183
|
### Wiring Check (Level 3)
|
|
122
184
|
|
|
123
185
|
```bash
|
package/bin/cli.js
CHANGED
|
@@ -160,10 +160,15 @@ const QUALIA_AGENT_FILES = [
|
|
|
160
160
|
];
|
|
161
161
|
|
|
162
162
|
// 3 Qualia bin scripts.
|
|
163
|
-
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js", "plan-contract.js", "agent-runs.js"];
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
|
|
163
|
+
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js", "plan-contract.js", "agent-runs.js", "slop-detect.mjs"];
|
|
164
|
+
|
|
165
|
+
// Qualia rules — security, deployment, infra, grounding, plus the v4.5.0 design substrate.
|
|
166
|
+
// frontend.md and design-reference.md are kept for backward compat; new projects use design-laws/brand/product/rubric.
|
|
167
|
+
const QUALIA_RULE_FILES = [
|
|
168
|
+
"security.md", "deployment.md", "infrastructure.md", "grounding.md",
|
|
169
|
+
"frontend.md", "design-reference.md",
|
|
170
|
+
"design-laws.md", "design-brand.md", "design-product.md", "design-rubric.md",
|
|
171
|
+
];
|
|
167
172
|
|
|
168
173
|
function promptYesNo(question, defaultYes) {
|
|
169
174
|
return new Promise((resolve) => {
|
|
@@ -1215,7 +1220,7 @@ function cmdHelp() {
|
|
|
1215
1220
|
console.log(` ${TG}/qualia-plan${RESET} Plan a phase`);
|
|
1216
1221
|
console.log(` ${TG}/qualia-build${RESET} Build it (parallel tasks)`);
|
|
1217
1222
|
console.log(` ${TG}/qualia-verify${RESET} Verify it works`);
|
|
1218
|
-
console.log(` ${TG}/qualia-
|
|
1223
|
+
console.log(` ${TG}/qualia-polish${RESET} Design pass — any scope (component, route, app, redesign)`);
|
|
1219
1224
|
console.log(` ${TG}/qualia-debug${RESET} Structured debugging`);
|
|
1220
1225
|
console.log(` ${TG}/qualia-review${RESET} Production audit`);
|
|
1221
1226
|
console.log(` ${TG}/qualia-ship${RESET} Deploy to production`);
|
package/bin/install.js
CHANGED
|
@@ -432,6 +432,12 @@ async function main() {
|
|
|
432
432
|
path.join(binDest, "agent-runs.js")
|
|
433
433
|
);
|
|
434
434
|
ok("agent-runs.js (agent telemetry writer)");
|
|
435
|
+
copy(
|
|
436
|
+
path.join(FRAMEWORK_DIR, "bin", "slop-detect.mjs"),
|
|
437
|
+
path.join(binDest, "slop-detect.mjs")
|
|
438
|
+
);
|
|
439
|
+
fs.chmodSync(path.join(binDest, "slop-detect.mjs"), 0o755);
|
|
440
|
+
ok("slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)");
|
|
435
441
|
} catch (e) {
|
|
436
442
|
warn(`scripts — ${e.message}`);
|
|
437
443
|
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* slop-detect — Standalone anti-pattern scanner for Qualia projects.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node bin/slop-detect.mjs # scan whole repo (default globs)
|
|
7
|
+
* node bin/slop-detect.mjs path/to/file.tsx # scan one file
|
|
8
|
+
* node bin/slop-detect.mjs src/components/ # scan a directory
|
|
9
|
+
* node bin/slop-detect.mjs --json # machine-readable output
|
|
10
|
+
* node bin/slop-detect.mjs --severity=critical # only critical findings
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 no critical findings
|
|
14
|
+
* 1 one or more critical findings
|
|
15
|
+
* 2 invocation error
|
|
16
|
+
*
|
|
17
|
+
* Builder agents call this BEFORE commit. A non-zero exit blocks the commit.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
21
|
+
import { join, extname, relative, resolve } from "node:path";
|
|
22
|
+
import { argv, exit, cwd } from "node:process";
|
|
23
|
+
|
|
24
|
+
// ── Severity rubric (from rules/grounding.md) ─────────────────────────
|
|
25
|
+
const CRITICAL = "critical";
|
|
26
|
+
const HIGH = "high";
|
|
27
|
+
const MEDIUM = "medium";
|
|
28
|
+
const LOW = "low";
|
|
29
|
+
|
|
30
|
+
// ── Anti-patterns ─────────────────────────────────────────────────────
|
|
31
|
+
// Each rule: { id, severity, label, fileGlob, pattern, allow?, fix }
|
|
32
|
+
const RULES = [
|
|
33
|
+
// ── CRITICAL: absolute bans (block commit) ──────────────────────────
|
|
34
|
+
{
|
|
35
|
+
id: "ABS-FONT",
|
|
36
|
+
severity: CRITICAL,
|
|
37
|
+
label: "Banned font (Inter/Roboto/Arial/system-ui/Space Grotesk)",
|
|
38
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
|
|
39
|
+
pattern: /(font-family|fontFamily)[^;{,]*(['"`])(Inter|Roboto|Arial|Helvetica|system-ui|Space\s*Grotesk)\b/i,
|
|
40
|
+
allow: /Inter\s*Display|Inter\s*Tight/,
|
|
41
|
+
fix: "Replace with a distinctive font (Fraunces, Geist, Söhne, JetBrains Mono, etc). See DESIGN.md §3.",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "ABS-PURPLE-GRAD",
|
|
45
|
+
severity: CRITICAL,
|
|
46
|
+
label: "Purple-blue gradient (the #1 AI-design tell)",
|
|
47
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
|
|
48
|
+
pattern: /(from-(blue|indigo|violet)-\d+\s+to-(purple|violet|fuchsia|pink)-\d+|from-(purple|violet|fuchsia|pink)-\d+\s+to-(blue|indigo|violet)-\d+|linear-gradient[^;]*(blue|indigo)[^;]*(purple|violet|fuchsia)|linear-gradient[^;]*(purple|violet|fuchsia)[^;]*(blue|indigo))/i,
|
|
49
|
+
fix: "Use one solid brand accent color. Emphasis via weight or size, not gradient text/bg.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "ABS-GRADIENT-TEXT",
|
|
53
|
+
severity: CRITICAL,
|
|
54
|
+
label: "Gradient text (background-clip: text)",
|
|
55
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
|
|
56
|
+
pattern: /(background-clip\s*:\s*text|bg-clip-text|-webkit-background-clip\s*:\s*text)/i,
|
|
57
|
+
fix: "Decorative, never meaningful. Use a single solid color. Emphasis via weight or size.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "ABS-PURE-BLACK-WHITE",
|
|
61
|
+
severity: CRITICAL,
|
|
62
|
+
label: "Pure #000 or #fff (untuned, generic)",
|
|
63
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
64
|
+
pattern: /#(000000|FFFFFF|000|FFF)\b/,
|
|
65
|
+
allow: /shadow|outline|currentColor|var\(/i,
|
|
66
|
+
fix: "Tint every neutral toward brand hue. Use OKLCH with chroma 0.005-0.015. See design-laws.md §1.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "ABS-HEX-IN-JSX",
|
|
70
|
+
severity: CRITICAL,
|
|
71
|
+
label: "Hardcoded hex color in JSX/TSX (use design tokens)",
|
|
72
|
+
fileGlob: /\.(tsx|jsx)$/,
|
|
73
|
+
pattern: /style=\{[^}]*['"]#[0-9a-fA-F]{3,8}['"]/,
|
|
74
|
+
fix: "Move color to a CSS custom property in DESIGN.md tokens. Reference via var(--name).",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "ABS-SIDE-STRIPE",
|
|
78
|
+
severity: CRITICAL,
|
|
79
|
+
label: "Side-stripe border (decorative border-left ≥2px)",
|
|
80
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
81
|
+
pattern: /border-(left|right)\s*:\s*\d+(px|rem)\s+solid|border-l-(2|4|8)|border-r-(2|4|8)/,
|
|
82
|
+
allow: /focus|active|outline/,
|
|
83
|
+
fix: "Use full borders, background tints, leading icons, or nothing. See design-laws.md §8.",
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// ── HIGH: strong tells ───────────────────────────────────────────────
|
|
87
|
+
{
|
|
88
|
+
id: "HI-MAX-W-CAP",
|
|
89
|
+
severity: HIGH,
|
|
90
|
+
label: "Hardcoded max-width container (no fluid full-width)",
|
|
91
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
92
|
+
pattern: /(max-w-7xl|max-w-\[1200|max-w-\[1280|max-w-\[1440|max-width\s*:\s*1200|max-width\s*:\s*1280)/,
|
|
93
|
+
fix: "Use fluid padding: clamp(1rem, 5vw, 4rem). Cap content via max-width: 65ch on prose only.",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "HI-OUTLINE-NONE",
|
|
97
|
+
severity: HIGH,
|
|
98
|
+
label: "outline:none without focus replacement (a11y violation)",
|
|
99
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
100
|
+
pattern: /outline\s*:\s*none|outline-none/,
|
|
101
|
+
allow: /focus-visible|focus:ring|focus:outline/,
|
|
102
|
+
fix: "Replace with a visible focus ring: 2px offset, contrasting color. See design-laws.md §accessibility.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "HI-IMG-NO-ALT",
|
|
106
|
+
severity: HIGH,
|
|
107
|
+
label: "<img> without alt attribute",
|
|
108
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
|
|
109
|
+
pattern: /<img\s+(?![^>]*\balt=)[^>]*>/,
|
|
110
|
+
fix: "Every image needs alt text. Decorative: alt=\"\" + aria-hidden=\"true\". Meaningful: describe it.",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "HI-GENERIC-CTA",
|
|
114
|
+
severity: HIGH,
|
|
115
|
+
label: "Generic CTA copy (Get Started / Learn More / Click Here)",
|
|
116
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro|md)$/,
|
|
117
|
+
pattern: />\s*(Get Started|Learn More|Click Here|Welcome to|Read More|Find Out More)\s*</i,
|
|
118
|
+
fix: "Name the action: 'Download invoice', 'Continue setup', 'Try the demo'.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "HI-EM-DASH",
|
|
122
|
+
severity: HIGH,
|
|
123
|
+
label: "Em dash in user-facing copy (— or '--')",
|
|
124
|
+
// Scope: shipped UI files only. Markdown / docs prose is allowed em-dashes.
|
|
125
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
|
|
126
|
+
pattern: />\s*[^<]*[—][^<]*</,
|
|
127
|
+
fix: "Use commas, colons, semicolons, periods, or parentheses. See design-laws.md §7.",
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// ── MEDIUM: noisy signals ────────────────────────────────────────────
|
|
131
|
+
{
|
|
132
|
+
id: "MED-CARD-GRID-3",
|
|
133
|
+
severity: MEDIUM,
|
|
134
|
+
label: "Three/four-column card grid (likely identical-card slop)",
|
|
135
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
|
|
136
|
+
pattern: /(grid-cols-3|grid-cols-4|grid-template-columns\s*:\s*repeat\(\s*[34]\s*,)/,
|
|
137
|
+
fix: "Vary card sizes and content shapes. Identical cards in a grid is the AI default hero pattern.",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "MED-GLASSMORPHISM",
|
|
141
|
+
severity: MEDIUM,
|
|
142
|
+
label: "Glassmorphism (backdrop-blur on multiple surfaces — likely default)",
|
|
143
|
+
fileGlob: /\.(tsx|jsx|css|scss)$/,
|
|
144
|
+
pattern: /(backdrop-blur|backdrop-filter\s*:\s*blur)/,
|
|
145
|
+
fix: "Glass effects are rare and purposeful, or nothing. Don't use them as default decoration.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "MED-CONTAINER-DEPTH",
|
|
149
|
+
severity: MEDIUM,
|
|
150
|
+
label: "Possible container-depth >2 (card on card on pill)",
|
|
151
|
+
fileGlob: /\.(tsx|jsx)$/,
|
|
152
|
+
pattern: /<div[^>]*className="[^"]*\b(card|panel|surface)[^"]*"[^>]*>\s*<div[^>]*className="[^"]*\b(card|panel|surface)/,
|
|
153
|
+
fix: "Container depth max 2. Flatten nested cards into a single container with content.",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "MED-ANIMATE-LAYOUT",
|
|
157
|
+
severity: MEDIUM,
|
|
158
|
+
label: "Animating layout properties (causes reflow, jank)",
|
|
159
|
+
fileGlob: /\.(tsx|jsx|css|scss)$/,
|
|
160
|
+
pattern: /transition\s*:\s*(width|height|top|left|right|bottom|margin|padding)\s/,
|
|
161
|
+
fix: "Animate transform and opacity only. Layout properties trigger reflow.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "MED-BOUNCE-EASING",
|
|
165
|
+
severity: MEDIUM,
|
|
166
|
+
label: "Bounce/elastic easing (banned per design-laws.md §6)",
|
|
167
|
+
fileGlob: /\.(tsx|jsx|css|scss|js|ts)$/,
|
|
168
|
+
pattern: /(bounce|elastic|backIn|backOut|cubic-bezier\([^)]*1\.\d+)/i,
|
|
169
|
+
fix: "Ease out with exponential curves: cubic-bezier(0.22, 1, 0.36, 1) or (0.16, 1, 0.3, 1).",
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// ── LOW: cleanup ─────────────────────────────────────────────────────
|
|
173
|
+
{
|
|
174
|
+
id: "LOW-CONSOLE-LOG",
|
|
175
|
+
severity: LOW,
|
|
176
|
+
label: "console.log in production code",
|
|
177
|
+
fileGlob: /\.(tsx|jsx|ts|js)$/,
|
|
178
|
+
pattern: /console\.(log|debug)\(/,
|
|
179
|
+
allow: /\/\/\s*(debug|todo)|test\.|spec\./i,
|
|
180
|
+
fix: "Remove or replace with a proper logger.",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
// ── File walker ───────────────────────────────────────────────────────
|
|
185
|
+
const SKIP_DIRS = new Set([
|
|
186
|
+
"node_modules", ".next", "dist", "build", ".git", ".turbo",
|
|
187
|
+
"coverage", ".cache", "out", ".vercel", ".vscode", ".idea",
|
|
188
|
+
".planning", ".qa-screenshots",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
function* walk(dir) {
|
|
192
|
+
let entries;
|
|
193
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
194
|
+
for (const name of entries) {
|
|
195
|
+
if (name.startsWith(".") && !["src", "app", "components", "lib"].includes(name)) {
|
|
196
|
+
// skip dotfiles except known source dirs
|
|
197
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
198
|
+
}
|
|
199
|
+
const path = join(dir, name);
|
|
200
|
+
let st;
|
|
201
|
+
try { st = statSync(path); } catch { continue; }
|
|
202
|
+
if (st.isDirectory()) {
|
|
203
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
204
|
+
yield* walk(path);
|
|
205
|
+
} else if (st.isFile()) {
|
|
206
|
+
yield path;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Scanner ───────────────────────────────────────────────────────────
|
|
212
|
+
function scanFile(path) {
|
|
213
|
+
const findings = [];
|
|
214
|
+
let content;
|
|
215
|
+
try { content = readFileSync(path, "utf8"); } catch { return findings; }
|
|
216
|
+
const lines = content.split("\n");
|
|
217
|
+
|
|
218
|
+
for (const rule of RULES) {
|
|
219
|
+
if (!rule.fileGlob.test(path)) continue;
|
|
220
|
+
for (let i = 0; i < lines.length; i++) {
|
|
221
|
+
const line = lines[i];
|
|
222
|
+
if (!rule.pattern.test(line)) continue;
|
|
223
|
+
if (rule.allow && rule.allow.test(line)) continue;
|
|
224
|
+
findings.push({
|
|
225
|
+
rule: rule.id,
|
|
226
|
+
severity: rule.severity,
|
|
227
|
+
label: rule.label,
|
|
228
|
+
file: path,
|
|
229
|
+
line: i + 1,
|
|
230
|
+
snippet: line.trim().slice(0, 120),
|
|
231
|
+
fix: rule.fix,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return findings;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── CLI ───────────────────────────────────────────────────────────────
|
|
239
|
+
function parseArgs(argv) {
|
|
240
|
+
const args = { paths: [], json: false, severity: null, help: false };
|
|
241
|
+
for (const a of argv.slice(2)) {
|
|
242
|
+
if (a === "--json") args.json = true;
|
|
243
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
244
|
+
else if (a.startsWith("--severity=")) args.severity = a.split("=")[1];
|
|
245
|
+
else if (a.startsWith("--")) {
|
|
246
|
+
console.error(`Unknown flag: ${a}`);
|
|
247
|
+
exit(2);
|
|
248
|
+
} else args.paths.push(a);
|
|
249
|
+
}
|
|
250
|
+
return args;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function help() {
|
|
254
|
+
console.log(`slop-detect — Qualia anti-pattern scanner
|
|
255
|
+
|
|
256
|
+
Usage:
|
|
257
|
+
slop-detect [path ...] [--json] [--severity=critical|high|medium|low]
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
slop-detect # scan whole repo
|
|
261
|
+
slop-detect src/components/Button.tsx # scan one file
|
|
262
|
+
slop-detect app/ # scan a directory
|
|
263
|
+
slop-detect --severity=critical # only critical findings
|
|
264
|
+
slop-detect --json > slop.json # machine-readable
|
|
265
|
+
|
|
266
|
+
Exit codes:
|
|
267
|
+
0 no critical findings
|
|
268
|
+
1 one or more critical findings
|
|
269
|
+
2 invocation error
|
|
270
|
+
`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function severityOrder(s) { return { critical: 4, high: 3, medium: 2, low: 1 }[s] || 0; }
|
|
274
|
+
function severityColor(s) { return { critical: "\x1b[31m", high: "\x1b[33m", medium: "\x1b[36m", low: "\x1b[37m" }[s] || ""; }
|
|
275
|
+
const RESET = "\x1b[0m";
|
|
276
|
+
const DIM = "\x1b[2m";
|
|
277
|
+
const BOLD = "\x1b[1m";
|
|
278
|
+
|
|
279
|
+
function main() {
|
|
280
|
+
const args = parseArgs(argv);
|
|
281
|
+
if (args.help) { help(); exit(0); }
|
|
282
|
+
|
|
283
|
+
const targets = args.paths.length ? args.paths.map(p => resolve(p)) : ["app", "components", "src", "lib", "pages"]
|
|
284
|
+
.map(d => resolve(cwd(), d))
|
|
285
|
+
.filter(d => existsSync(d));
|
|
286
|
+
|
|
287
|
+
if (targets.length === 0) targets.push(resolve(cwd()));
|
|
288
|
+
|
|
289
|
+
// Collect files
|
|
290
|
+
const files = [];
|
|
291
|
+
for (const t of targets) {
|
|
292
|
+
let st;
|
|
293
|
+
try { st = statSync(t); } catch {
|
|
294
|
+
console.error(`Path does not exist: ${t}`);
|
|
295
|
+
exit(2);
|
|
296
|
+
}
|
|
297
|
+
if (st.isFile()) files.push(t);
|
|
298
|
+
else for (const f of walk(t)) files.push(f);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Scan
|
|
302
|
+
const findings = [];
|
|
303
|
+
for (const f of files) findings.push(...scanFile(f));
|
|
304
|
+
|
|
305
|
+
// Filter by severity
|
|
306
|
+
const minSev = args.severity ? severityOrder(args.severity) : 1;
|
|
307
|
+
const filtered = findings.filter(f => severityOrder(f.severity) >= minSev);
|
|
308
|
+
|
|
309
|
+
// Output
|
|
310
|
+
if (args.json) {
|
|
311
|
+
console.log(JSON.stringify({
|
|
312
|
+
scanned_files: files.length,
|
|
313
|
+
total_findings: filtered.length,
|
|
314
|
+
by_severity: {
|
|
315
|
+
critical: filtered.filter(f => f.severity === CRITICAL).length,
|
|
316
|
+
high: filtered.filter(f => f.severity === HIGH).length,
|
|
317
|
+
medium: filtered.filter(f => f.severity === MEDIUM).length,
|
|
318
|
+
low: filtered.filter(f => f.severity === LOW).length,
|
|
319
|
+
},
|
|
320
|
+
findings: filtered,
|
|
321
|
+
}, null, 2));
|
|
322
|
+
} else {
|
|
323
|
+
if (filtered.length === 0) {
|
|
324
|
+
console.log(`${BOLD}\x1b[32m✓ no slop detected${RESET} (${files.length} files scanned)`);
|
|
325
|
+
} else {
|
|
326
|
+
// Group by severity, sorted highest first
|
|
327
|
+
const bySev = {};
|
|
328
|
+
for (const f of filtered) (bySev[f.severity] ||= []).push(f);
|
|
329
|
+
const order = ["critical", "high", "medium", "low"];
|
|
330
|
+
for (const sev of order) {
|
|
331
|
+
const items = bySev[sev];
|
|
332
|
+
if (!items) continue;
|
|
333
|
+
const color = severityColor(sev);
|
|
334
|
+
console.log(`\n${color}${BOLD}${sev.toUpperCase()}${RESET} ${items.length} finding${items.length === 1 ? "" : "s"}`);
|
|
335
|
+
console.log(`${DIM}${"─".repeat(60)}${RESET}`);
|
|
336
|
+
for (const f of items) {
|
|
337
|
+
const rel = relative(cwd(), f.file);
|
|
338
|
+
console.log(`${color}●${RESET} ${BOLD}${rel}:${f.line}${RESET} ${DIM}[${f.rule}]${RESET}`);
|
|
339
|
+
console.log(` ${f.label}`);
|
|
340
|
+
console.log(` ${DIM}${f.snippet}${RESET}`);
|
|
341
|
+
console.log(` ${color}→${RESET} ${f.fix}`);
|
|
342
|
+
console.log();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const crit = (bySev.critical || []).length;
|
|
346
|
+
const total = filtered.length;
|
|
347
|
+
console.log(`${BOLD}${total}${RESET} total · ${BOLD}${crit}${RESET} critical · ${files.length} files scanned`);
|
|
348
|
+
if (crit > 0) console.log(`${severityColor("critical")}${BOLD}commit blocked${RESET} — fix critical findings first`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Exit code
|
|
353
|
+
const criticalCount = filtered.filter(f => f.severity === CRITICAL).length;
|
|
354
|
+
exit(criticalCount > 0 ? 1 : 0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
main();
|