fe-inspector-mcp 0.1.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/README.md +194 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +140 -0
- package/dist/inspector/a11y.d.ts +8 -0
- package/dist/inspector/a11y.js +142 -0
- package/dist/inspector/aesthetic.d.ts +8 -0
- package/dist/inspector/aesthetic.js +146 -0
- package/dist/inspector/index.d.ts +8 -0
- package/dist/inspector/index.js +82 -0
- package/dist/inspector/interaction.d.ts +8 -0
- package/dist/inspector/interaction.js +141 -0
- package/dist/inspector/responsive.d.ts +8 -0
- package/dist/inspector/responsive.js +149 -0
- package/dist/reporter/index.d.ts +3 -0
- package/dist/reporter/index.js +68 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
- package/skills/fe-inspect.md +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# FE Inspector MCP
|
|
2
|
+
|
|
3
|
+
**A frontend quality inspection MCP Server for AI coding agents.**
|
|
4
|
+
|
|
5
|
+
hallmark checks aesthetic. FE Inspector checks aesthetic + accessibility + responsive + interaction — because a good UI isn't just pretty, it works.
|
|
6
|
+
|
|
7
|
+
## Real-world comparison
|
|
8
|
+
|
|
9
|
+
We took a typical AI-generated SaaS landing page (purple gradient, stacked cards, missing alt text, no viewport meta, dead links — the kind Claude Code or Cursor outputs in one shot) and ran both tools on it.
|
|
10
|
+
|
|
11
|
+
| | hallmark | FE Inspector |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **Issues caught** | 2 | **16** |
|
|
14
|
+
| **Coverage** | Aesthetic only | Aesthetic + A11y + Responsive + Interaction |
|
|
15
|
+
|
|
16
|
+
**hallmark caught:**
|
|
17
|
+
- Dense content spacing issue
|
|
18
|
+
- Stacked card layout detection
|
|
19
|
+
|
|
20
|
+
**FE Inspector caught ALL of those PLUS:**
|
|
21
|
+
- 2 images missing alt text 🚨
|
|
22
|
+
- 4 clickable divs without roles 🚨
|
|
23
|
+
- Missing viewport meta tag 🚨
|
|
24
|
+
- No CSS media queries (not responsive)
|
|
25
|
+
- Multi-column grid without mobile breakpoint
|
|
26
|
+
- No hover/focus visual feedback on interactive elements
|
|
27
|
+
- 2 links with no destination (dead links)
|
|
28
|
+
- Missing pointer cursor on buttons
|
|
29
|
+
|
|
30
|
+
[See the sample page →](docs/sample-ai-page.html) | [Run it yourself →](#installation)
|
|
31
|
+
|
|
32
|
+
hallmark is a great aesthetic checker. FE Inspector is a **complete frontend quality gate**.
|
|
33
|
+
|
|
34
|
+
## Why this exists
|
|
35
|
+
|
|
36
|
+
[hallmark](https://github.com/Nutlope/hallmark) proved that developers care about AI-generated UI quality. But hallmark only checks **visual patterns** (gradients, spacing, fonts). Your users also care about:
|
|
37
|
+
|
|
38
|
+
- Can a screen reader navigate this page?
|
|
39
|
+
- Does it work on a phone?
|
|
40
|
+
- Does the button actually respond when clicked?
|
|
41
|
+
|
|
42
|
+
FE Inspector closes that gap.
|
|
43
|
+
|
|
44
|
+
## What it checks (20 rules)
|
|
45
|
+
|
|
46
|
+
### Aesthetic (5 rules)
|
|
47
|
+
- Purple-blue gradient detection
|
|
48
|
+
- Excessive border-radius
|
|
49
|
+
- Low contrast text
|
|
50
|
+
- Dense content without separation
|
|
51
|
+
- Stacked-card layout detection
|
|
52
|
+
|
|
53
|
+
### Accessibility (5 rules)
|
|
54
|
+
- Missing image alt text
|
|
55
|
+
- Interactive elements without labels
|
|
56
|
+
- Form inputs without associated labels
|
|
57
|
+
- Positive tabindex values
|
|
58
|
+
- Clickable divs without proper roles
|
|
59
|
+
|
|
60
|
+
### Responsive (5 rules)
|
|
61
|
+
- Fixed pixel widths on containers
|
|
62
|
+
- Missing viewport meta tag
|
|
63
|
+
- No CSS media queries
|
|
64
|
+
- Rigid grid columns without mobile fallback
|
|
65
|
+
- Touch targets below 44px
|
|
66
|
+
|
|
67
|
+
### Interaction (5 rules)
|
|
68
|
+
- Missing pointer cursor on interactive elements
|
|
69
|
+
- No hover/focus visual feedback
|
|
70
|
+
- Forms without submit mechanisms
|
|
71
|
+
- Empty or hash-only links
|
|
72
|
+
- Disabled elements without explanation
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Install globally via npm
|
|
78
|
+
npm install -g fe-inspector-mcp
|
|
79
|
+
|
|
80
|
+
# Or run directly with npx
|
|
81
|
+
npx fe-inspector-mcp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## MCP configuration
|
|
85
|
+
|
|
86
|
+
Add to your MCP client config (e.g., `~/.codex/config.toml` or Cursor MCP settings):
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"fe-inspector": {
|
|
92
|
+
"command": "npx",
|
|
93
|
+
"args": ["fe-inspector-mcp"]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or for local development:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"mcpServers": {
|
|
104
|
+
"fe-inspector": {
|
|
105
|
+
"command": "node",
|
|
106
|
+
"args": ["/path/to/fe-inspector-mcp/dist/index.js"]
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Available tools
|
|
113
|
+
|
|
114
|
+
### `inspect_html`
|
|
115
|
+
|
|
116
|
+
Run all quality checks on an HTML string.
|
|
117
|
+
|
|
118
|
+
**Parameters:**
|
|
119
|
+
- `html` (required): The HTML content to inspect
|
|
120
|
+
- `url` (optional): URL for context
|
|
121
|
+
- `categories` (optional): Specific categories to check (`aesthetic`, `a11y`, `responsive`, `interaction`)
|
|
122
|
+
- `format` (optional): `text` (default) or `json`
|
|
123
|
+
|
|
124
|
+
**Example output:**
|
|
125
|
+
```
|
|
126
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
127
|
+
FE Inspector — Frontend Quality Report
|
|
128
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
129
|
+
Generated: 2026-07-21T07:48:00Z
|
|
130
|
+
Elements: 187
|
|
131
|
+
Summary: Found 2 errors, 3 warnings, 15 passed.
|
|
132
|
+
|
|
133
|
+
── Aesthetic (2) ──
|
|
134
|
+
[!] ae-001: Avoid purple-blue gradient backgrounds
|
|
135
|
+
Element <section> uses a purple-blue gradient
|
|
136
|
+
Fix: Replace with a neutral or brand-specific scheme
|
|
137
|
+
|
|
138
|
+
── Accessibility (2) ──
|
|
139
|
+
[X] a11y-001: Missing alt attribute on <img>
|
|
140
|
+
Image "/hero.jpg" has no alt text
|
|
141
|
+
Fix: Add descriptive alt attribute
|
|
142
|
+
|
|
143
|
+
── Responsive (1) ──
|
|
144
|
+
[X] rp-002: Missing viewport <meta> tag
|
|
145
|
+
Without viewport meta, mobile browsers may scale wrongly
|
|
146
|
+
Fix: Add <meta name="viewport" content="width=device-width, initial-scale=1">
|
|
147
|
+
|
|
148
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
149
|
+
Quality Score: 75/100
|
|
150
|
+
15 passed | 3 warnings | 2 errors
|
|
151
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `inspect_html_fix`
|
|
155
|
+
|
|
156
|
+
Same as `inspect_html` but returns structured Markdown fix suggestions that coding agents can apply automatically.
|
|
157
|
+
|
|
158
|
+
### `inspect_categories`
|
|
159
|
+
|
|
160
|
+
List available categories and rule counts.
|
|
161
|
+
|
|
162
|
+
## Usage with agents
|
|
163
|
+
|
|
164
|
+
**Claude Code:**
|
|
165
|
+
```
|
|
166
|
+
claude
|
|
167
|
+
> inspect the HTML I just generated
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Codex CLI:**
|
|
171
|
+
```
|
|
172
|
+
codex
|
|
173
|
+
> run fe-inspector on the page output
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Cursor:**
|
|
177
|
+
Configure MCP server in Cursor settings → MCP → Add server.
|
|
178
|
+
|
|
179
|
+
## Compared to hallmark
|
|
180
|
+
|
|
181
|
+
| | hallmark | FE Inspector |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| **Real-world catch rate** (SaaS landing page) | 2 issues | **16 issues** (8x more) |
|
|
184
|
+
| **Aesthetic checks** | 57 rules | 5 focused rules |
|
|
185
|
+
| **Accessibility (a11y)** | ❌ | 5 rules |
|
|
186
|
+
| **Responsive (mobile)** | ❌ | 5 rules |
|
|
187
|
+
| **Interaction logic** | ❌ | 5 rules |
|
|
188
|
+
| **Total rules** | 57 (all aesthetic) | **20 (4 dimensions)** |
|
|
189
|
+
| **Output format** | Passive Skill (agent text) | MCP tools (text + JSON + fix suggestions) |
|
|
190
|
+
| **Distribution** | SKILL.md only | MCP Server + SKILL.md |
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { runInspection, getInspectorMeta } from './inspector/index.js';
|
|
6
|
+
import { generateTextReport, generateJsonReport } from './reporter/index.js';
|
|
7
|
+
const { version, name } = { version: '0.1.0', name: 'fe-inspector-mcp' };
|
|
8
|
+
const server = new Server({ name, version }, { capabilities: { tools: {} } });
|
|
9
|
+
// ── Tool definitions ──
|
|
10
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
11
|
+
const inspectors = getInspectorMeta();
|
|
12
|
+
return {
|
|
13
|
+
tools: [
|
|
14
|
+
{
|
|
15
|
+
name: 'inspect_html',
|
|
16
|
+
description: `Run all quality checks on an HTML string. Returns detailed report in text format.`,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
html: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'The HTML content to inspect',
|
|
23
|
+
},
|
|
24
|
+
url: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Optional URL for context in the report',
|
|
27
|
+
},
|
|
28
|
+
categories: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
enum: ['aesthetic', 'a11y', 'responsive', 'interaction'],
|
|
33
|
+
},
|
|
34
|
+
description: 'Categories to check (omit for all)',
|
|
35
|
+
},
|
|
36
|
+
format: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: ['text', 'json'],
|
|
39
|
+
description: 'Report format (default: text)',
|
|
40
|
+
default: 'text',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ['html'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'inspect_categories',
|
|
48
|
+
description: 'List available inspection categories and their rule counts.',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'inspect_html_fix',
|
|
56
|
+
description: 'Run all checks and return AI-friendly fix suggestions in a structured format.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
html: { type: 'string', description: 'The HTML content to inspect' },
|
|
61
|
+
categories: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
items: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
enum: ['aesthetic', 'a11y', 'responsive', 'interaction'],
|
|
66
|
+
},
|
|
67
|
+
description: 'Categories to check (omit for all)',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ['html'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
// ── Tool handlers ──
|
|
77
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
78
|
+
const { name: toolName, arguments: args } = request.params;
|
|
79
|
+
switch (toolName) {
|
|
80
|
+
case 'inspect_html': {
|
|
81
|
+
const html = String(args?.html || '');
|
|
82
|
+
const report = runInspection({
|
|
83
|
+
html,
|
|
84
|
+
url: args?.url,
|
|
85
|
+
categories: args?.categories,
|
|
86
|
+
});
|
|
87
|
+
const format = args?.format || 'text';
|
|
88
|
+
const output = format === 'json' ? generateJsonReport(report) : generateTextReport(report);
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: 'text', text: output }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
case 'inspect_categories': {
|
|
94
|
+
const inspectors = getInspectorMeta();
|
|
95
|
+
const lines = inspectors.map((m) => `- ${m.label} (${m.ruleCount} rules): ${m.description}`);
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: `Available inspection categories:\n${lines.join('\n')}` }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
case 'inspect_html_fix': {
|
|
101
|
+
const html = String(args?.html || '');
|
|
102
|
+
const report = runInspection({
|
|
103
|
+
html,
|
|
104
|
+
categories: args?.categories,
|
|
105
|
+
});
|
|
106
|
+
// Build fix-oriented output
|
|
107
|
+
const fixLines = ['# FE Inspector Fix Suggestions\n'];
|
|
108
|
+
const catLabels = {
|
|
109
|
+
aesthetic: 'Aesthetic', a11y: 'Accessibility', responsive: 'Responsive', interaction: 'Interaction',
|
|
110
|
+
};
|
|
111
|
+
for (const [cat, detections] of Object.entries(report.categories)) {
|
|
112
|
+
if (detections.length === 0)
|
|
113
|
+
continue;
|
|
114
|
+
fixLines.push(`## ${catLabels[cat] || cat}`);
|
|
115
|
+
for (const d of detections) {
|
|
116
|
+
fixLines.push(`- **${d.ruleId}** (${d.severity}): ${d.title}`);
|
|
117
|
+
if (d.element)
|
|
118
|
+
fixLines.push(` - Location: \`${d.element}\``);
|
|
119
|
+
fixLines.push(` - Fix: ${d.recommendation}`);
|
|
120
|
+
}
|
|
121
|
+
fixLines.push('');
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: 'text', text: fixLines.join('\n') }],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
// ── Start server ──
|
|
132
|
+
async function main() {
|
|
133
|
+
const transport = new StdioServerTransport();
|
|
134
|
+
await server.connect(transport);
|
|
135
|
+
console.error('FE Inspector MCP Server running on stdio');
|
|
136
|
+
}
|
|
137
|
+
main().catch((err) => {
|
|
138
|
+
console.error('Fatal error:', err);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
const category = 'a11y';
|
|
3
|
+
const rules = [
|
|
4
|
+
{
|
|
5
|
+
id: 'a11y-001',
|
|
6
|
+
title: 'Images must have alt attributes',
|
|
7
|
+
check: ($) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
$('img').each((_i, el) => {
|
|
10
|
+
const $el = $(el);
|
|
11
|
+
if (!$el.attr('alt') && $el.attr('alt') !== '') {
|
|
12
|
+
results.push({
|
|
13
|
+
ruleId: 'a11y-001',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
title: 'Missing alt attribute on <img>',
|
|
16
|
+
message: `Image has no alt text.`,
|
|
17
|
+
element: `img[src="${$el.attr('src')}"]`,
|
|
18
|
+
recommendation: 'Add a descriptive alt attribute (or alt="" if decorative).',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return results;
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'a11y-002',
|
|
27
|
+
title: 'Interactive elements need discernible text',
|
|
28
|
+
check: ($) => {
|
|
29
|
+
const results = [];
|
|
30
|
+
$('button, a').each((_i, el) => {
|
|
31
|
+
const $el = $(el);
|
|
32
|
+
const text = $el.text().trim();
|
|
33
|
+
const ariaLabel = $el.attr('aria-label');
|
|
34
|
+
const innerImg = $el.find('img[alt], img[alt!=""]').length;
|
|
35
|
+
if (!text && !ariaLabel && !innerImg) {
|
|
36
|
+
results.push({
|
|
37
|
+
ruleId: 'a11y-002',
|
|
38
|
+
severity: 'error',
|
|
39
|
+
title: 'Interactive element has no accessible label',
|
|
40
|
+
message: `Element has no visible text, aria-label, or labelled child.`,
|
|
41
|
+
element: buildSelector($el),
|
|
42
|
+
recommendation: 'Add text content, an aria-label, or an icon with proper alt text.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return results;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'a11y-003',
|
|
51
|
+
title: 'Form inputs should be associated with a label',
|
|
52
|
+
check: ($) => {
|
|
53
|
+
const results = [];
|
|
54
|
+
$('input:not([type="hidden"]), textarea, select').each((_i, el) => {
|
|
55
|
+
const $el = $(el);
|
|
56
|
+
const id = $el.attr('id');
|
|
57
|
+
const ariaLabel = $el.attr('aria-label');
|
|
58
|
+
const hasLabel = id ? $(`label[for="${id}"]`).length > 0 : false;
|
|
59
|
+
const isWrapped = $el.parents('label').length > 0;
|
|
60
|
+
if (!ariaLabel && !hasLabel && !isWrapped) {
|
|
61
|
+
results.push({
|
|
62
|
+
ruleId: 'a11y-003',
|
|
63
|
+
severity: 'warning',
|
|
64
|
+
title: 'Form input without associated label',
|
|
65
|
+
message: `Input has no label, aria-label, or wrapping <label>.`,
|
|
66
|
+
element: buildSelector($el),
|
|
67
|
+
recommendation: id
|
|
68
|
+
? `Add <label for="${id}">...</label>.`
|
|
69
|
+
: 'Either wrap in a <label>, add an id and use <label for>, or add aria-label.',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return results;
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'a11y-004',
|
|
78
|
+
title: 'Avoid positive tabindex — it breaks natural tab order',
|
|
79
|
+
check: ($) => {
|
|
80
|
+
const results = [];
|
|
81
|
+
$('[tabindex]').each((_i, el) => {
|
|
82
|
+
const val = parseInt($(el).attr('tabindex') || '0');
|
|
83
|
+
if (val > 0) {
|
|
84
|
+
results.push({
|
|
85
|
+
ruleId: 'a11y-004',
|
|
86
|
+
severity: 'warning',
|
|
87
|
+
title: 'Positive tabindex value disrupts keyboard navigation',
|
|
88
|
+
message: `tabindex="${val}" forces non-standard tab order.`,
|
|
89
|
+
element: buildSelector($(el)),
|
|
90
|
+
recommendation: 'Use tabindex="0" to follow DOM order, or tabindex="-1" to remove from tab flow.',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return results;
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'a11y-005',
|
|
99
|
+
title: 'Clickable <div> should have proper role',
|
|
100
|
+
check: ($) => {
|
|
101
|
+
const results = [];
|
|
102
|
+
$('div').each((_i, el) => {
|
|
103
|
+
const $el = $(el);
|
|
104
|
+
const role = $el.attr('role');
|
|
105
|
+
const hasOnClick = $el.attr('onclick') || $el.attr('ng-click');
|
|
106
|
+
if (hasOnClick && !role) {
|
|
107
|
+
results.push({
|
|
108
|
+
ruleId: 'a11y-005',
|
|
109
|
+
severity: 'warning',
|
|
110
|
+
title: 'Clickable <div> without role',
|
|
111
|
+
message: 'A <div> has a click handler but no role — screen readers will not announce it as interactive.',
|
|
112
|
+
element: buildSelector($el),
|
|
113
|
+
recommendation: 'Use <button> instead, or add role="button" + keyboard event handlers.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return results;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
function buildSelector($el) {
|
|
122
|
+
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
123
|
+
const id = $el.attr?.('id');
|
|
124
|
+
if (id)
|
|
125
|
+
return `#${id}`;
|
|
126
|
+
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
127
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
128
|
+
}
|
|
129
|
+
export function inspectA11y(html) {
|
|
130
|
+
const $ = load(html);
|
|
131
|
+
const results = [];
|
|
132
|
+
for (const rule of rules) {
|
|
133
|
+
results.push(...rule.check($));
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
export const a11yMeta = {
|
|
138
|
+
category,
|
|
139
|
+
label: 'Accessibility',
|
|
140
|
+
description: 'A11y — alt text, labels, keyboard nav, semantic HTML',
|
|
141
|
+
ruleCount: rules.length,
|
|
142
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
const category = 'aesthetic';
|
|
3
|
+
const rules = [
|
|
4
|
+
{
|
|
5
|
+
id: 'ae-001',
|
|
6
|
+
title: 'Avoid purple-blue gradient backgrounds on top-level containers',
|
|
7
|
+
check: ($) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
$('div, section, header, main, footer').each((_i, el) => {
|
|
10
|
+
const style = $(el).attr('style') || '';
|
|
11
|
+
if (/linear-gradient[^;]*(purple|indigo|violet|#6[0-9a-f]{2}|#7[0-9a-f]{2}|#8[0-9a-f]{2}|#9[0-9a-f]{2})/i.test(style)) {
|
|
12
|
+
results.push({
|
|
13
|
+
ruleId: 'ae-001',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
title: 'AI-typical gradient detected',
|
|
16
|
+
message: `Element uses a purple-blue gradient — a hallmark (no pun) of AI-generated UIs.`,
|
|
17
|
+
element: buildSelector($(el)),
|
|
18
|
+
recommendation: 'Replace with a neutral or brand-specific color scheme. Solid backgrounds + accent colors feel more intentional.',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return results;
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'ae-002',
|
|
27
|
+
title: 'Check for excessive border-radius (pill-shaped everything)',
|
|
28
|
+
check: ($) => {
|
|
29
|
+
const results = [];
|
|
30
|
+
$('div, button, input, textarea, select').each((_i, el) => {
|
|
31
|
+
const style = $(el).attr('style') || '';
|
|
32
|
+
const match = style.match(/border-radius\s*:\s*(\d+)/i);
|
|
33
|
+
if (match && parseInt(match[1]) > 20) {
|
|
34
|
+
results.push({
|
|
35
|
+
ruleId: 'ae-002',
|
|
36
|
+
severity: 'info',
|
|
37
|
+
title: 'Large border-radius on non-card element',
|
|
38
|
+
message: `border-radius: ${match[1]}px may look overly rounded.`,
|
|
39
|
+
element: buildSelector($(el)),
|
|
40
|
+
recommendation: 'Use 6-12px for most elements; pill shape is for tags/badges only.',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return results;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'ae-003',
|
|
49
|
+
title: 'Low contrast text is hard to read',
|
|
50
|
+
check: ($) => {
|
|
51
|
+
const results = [];
|
|
52
|
+
$('p, span, li, a, label, h1, h2, h3, h4, h5, h6').each((_i, el) => {
|
|
53
|
+
const style = $(el).attr('style') || '';
|
|
54
|
+
const colorMatch = style.match(/color\s*:\s*(#[0-9a-fA-F]{3,6}|rgba?\([^)]+\))/);
|
|
55
|
+
if (!colorMatch)
|
|
56
|
+
return;
|
|
57
|
+
const bgMatch = style.match(/background(?:-color)?\s*:\s*(#[0-9a-fA-F]{3,6}|rgba?\([^)]+\))/);
|
|
58
|
+
const hasLightText = /(#[89a-f][0-9a-f]{2}|#[0-9a-f]{5}[89a-f]|rgba?\(.*255.*255.*255.*0\.[0-5])/i.test(colorMatch[1]);
|
|
59
|
+
if (hasLightText && bgMatch && /white|#fff|transparent/i.test(bgMatch[1])) {
|
|
60
|
+
results.push({
|
|
61
|
+
ruleId: 'ae-003',
|
|
62
|
+
severity: 'warning',
|
|
63
|
+
title: 'Low contrast light text on light background',
|
|
64
|
+
message: `Light-colored text may be hard to read.`,
|
|
65
|
+
element: buildSelector($(el)),
|
|
66
|
+
recommendation: 'Ensure text meets WCAG AA contrast ratio (4.5:1 for body, 3:1 for large text).',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return results;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'ae-004',
|
|
75
|
+
title: 'Overly compact content lacks breathing room',
|
|
76
|
+
check: ($) => {
|
|
77
|
+
const results = [];
|
|
78
|
+
$('body > *:first-child').each((_i, el) => {
|
|
79
|
+
const children = $(el).children().toArray();
|
|
80
|
+
if (children.length <= 3)
|
|
81
|
+
return;
|
|
82
|
+
let dense = true;
|
|
83
|
+
for (let i = 0; i < Math.min(children.length, 5); i++) {
|
|
84
|
+
if (!children[i + 1])
|
|
85
|
+
break;
|
|
86
|
+
if (($(children[i]).html()?.length || 0) > 200) {
|
|
87
|
+
dense = false;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (dense) {
|
|
92
|
+
results.push({
|
|
93
|
+
ruleId: 'ae-004',
|
|
94
|
+
severity: 'info',
|
|
95
|
+
title: 'Dense content without visual separation',
|
|
96
|
+
message: 'Multiple elements stacked without margins or dividers — feels cramped.',
|
|
97
|
+
element: buildSelector($(el)),
|
|
98
|
+
recommendation: 'Add consistent spacing, separators, or background alternation between sections.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return results;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'ae-005',
|
|
107
|
+
title: 'Avoid generic stacked-card layout',
|
|
108
|
+
check: ($) => {
|
|
109
|
+
const results = [];
|
|
110
|
+
const cardCount = $('div.card, div[class*="card"], div[class*="feature"]').length;
|
|
111
|
+
if (cardCount >= 3) {
|
|
112
|
+
results.push({
|
|
113
|
+
ruleId: 'ae-005',
|
|
114
|
+
severity: 'info',
|
|
115
|
+
title: 'Suspected generic stacked-card layout',
|
|
116
|
+
message: `Found ${cardCount} card-like elements in a row — common AI pattern.`,
|
|
117
|
+
element: 'body',
|
|
118
|
+
recommendation: 'Vary the layout: alternate image/text sides, use different card heights, or break the grid with a full-width section.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return results;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
function buildSelector($el) {
|
|
126
|
+
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
127
|
+
const id = $el.attr?.('id');
|
|
128
|
+
if (id)
|
|
129
|
+
return `#${id}`;
|
|
130
|
+
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
131
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
132
|
+
}
|
|
133
|
+
export function inspectAesthetic(html) {
|
|
134
|
+
const $ = load(html);
|
|
135
|
+
const results = [];
|
|
136
|
+
for (const rule of rules) {
|
|
137
|
+
results.push(...rule.check($));
|
|
138
|
+
}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
export const aestheticMeta = {
|
|
142
|
+
category,
|
|
143
|
+
label: 'Aesthetic',
|
|
144
|
+
description: 'Visual quality — gradients, contrast, spacing, layout patterns',
|
|
145
|
+
ruleCount: rules.length,
|
|
146
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InspectInput, InspectReport } from '../types.js';
|
|
2
|
+
export declare function runInspection(input: InspectInput): InspectReport;
|
|
3
|
+
export declare function getInspectorMeta(): {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description: string;
|
|
7
|
+
ruleCount: number;
|
|
8
|
+
}[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { inspectAesthetic, aestheticMeta } from './aesthetic.js';
|
|
2
|
+
import { inspectA11y, a11yMeta } from './a11y.js';
|
|
3
|
+
import { inspectResponsive, responsiveMeta } from './responsive.js';
|
|
4
|
+
import { inspectInteraction, interactionMeta } from './interaction.js';
|
|
5
|
+
const inspectors = {
|
|
6
|
+
aesthetic: {
|
|
7
|
+
...aestheticMeta,
|
|
8
|
+
inspect: inspectAesthetic,
|
|
9
|
+
},
|
|
10
|
+
a11y: {
|
|
11
|
+
...a11yMeta,
|
|
12
|
+
inspect: inspectA11y,
|
|
13
|
+
},
|
|
14
|
+
responsive: {
|
|
15
|
+
...responsiveMeta,
|
|
16
|
+
inspect: inspectResponsive,
|
|
17
|
+
},
|
|
18
|
+
interaction: {
|
|
19
|
+
...interactionMeta,
|
|
20
|
+
inspect: inspectInteraction,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export function runInspection(input) {
|
|
24
|
+
const { html, url } = input;
|
|
25
|
+
const categories = input.categories || Object.keys(inspectors);
|
|
26
|
+
const categoryResults = {};
|
|
27
|
+
let total = 0;
|
|
28
|
+
let passed = 0;
|
|
29
|
+
let warnings = 0;
|
|
30
|
+
let errors = 0;
|
|
31
|
+
for (const cat of categories) {
|
|
32
|
+
const mod = inspectors[cat];
|
|
33
|
+
if (!mod)
|
|
34
|
+
continue;
|
|
35
|
+
const detections = mod.inspect(html);
|
|
36
|
+
categoryResults[cat] = detections;
|
|
37
|
+
total += detections.length;
|
|
38
|
+
for (const d of detections) {
|
|
39
|
+
if (d.severity === 'error')
|
|
40
|
+
errors++;
|
|
41
|
+
else if (d.severity === 'warning')
|
|
42
|
+
warnings++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Passed = rules that ran but found nothing
|
|
46
|
+
let totalRules = 0;
|
|
47
|
+
for (const cat of categories) {
|
|
48
|
+
totalRules += inspectors[cat]?.ruleCount || 0;
|
|
49
|
+
}
|
|
50
|
+
passed = totalRules - total;
|
|
51
|
+
// Generate summary
|
|
52
|
+
const parts = [];
|
|
53
|
+
if (errors > 0)
|
|
54
|
+
parts.push(`${errors} errors`);
|
|
55
|
+
if (warnings > 0)
|
|
56
|
+
parts.push(`${warnings} warnings`);
|
|
57
|
+
if (passed > 0)
|
|
58
|
+
parts.push(`${passed} rules passed`);
|
|
59
|
+
const summary = parts.length > 0
|
|
60
|
+
? `Found ${parts.join(', ')}.`
|
|
61
|
+
: 'All checks passed — no issues detected.';
|
|
62
|
+
// Count elements
|
|
63
|
+
const elementCount = (html.match(/<\w+/g) || []).length;
|
|
64
|
+
return {
|
|
65
|
+
url,
|
|
66
|
+
totalElements: elementCount,
|
|
67
|
+
passed,
|
|
68
|
+
warnings,
|
|
69
|
+
errors,
|
|
70
|
+
categories: categoryResults,
|
|
71
|
+
summary,
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function getInspectorMeta() {
|
|
76
|
+
return Object.entries(inspectors).map(([key, mod]) => ({
|
|
77
|
+
id: key,
|
|
78
|
+
label: mod.label,
|
|
79
|
+
description: mod.description,
|
|
80
|
+
ruleCount: mod.ruleCount,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
const category = 'interaction';
|
|
3
|
+
const rules = [
|
|
4
|
+
{
|
|
5
|
+
id: 'in-001',
|
|
6
|
+
title: 'Interactive elements should have pointer cursor',
|
|
7
|
+
check: ($) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
$('button, [role="button"], a[href]').each((_i, el) => {
|
|
10
|
+
const style = $(el).attr('style') || '';
|
|
11
|
+
if (!/cursor\s*:\s*pointer/i.test(style)) {
|
|
12
|
+
results.push({
|
|
13
|
+
ruleId: 'in-001',
|
|
14
|
+
severity: 'info',
|
|
15
|
+
title: 'Missing pointer cursor on interactive element',
|
|
16
|
+
message: `Element is clickable but cursor remains default.`,
|
|
17
|
+
element: buildSelector($(el)),
|
|
18
|
+
recommendation: 'Add cursor: pointer via CSS or inline style.',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return results;
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'in-002',
|
|
27
|
+
title: 'Buttons should have visual hover/focus feedback',
|
|
28
|
+
check: ($) => {
|
|
29
|
+
const results = [];
|
|
30
|
+
const html = $.html();
|
|
31
|
+
const hasHover = /button:hover|:hover\s*\{/i.test(html);
|
|
32
|
+
const hasFocus = /:focus\s*\{/i.test(html);
|
|
33
|
+
$('button, a[href], [role="button"]').each((_i, el) => {
|
|
34
|
+
const $el = $(el);
|
|
35
|
+
const cls = $el.attr('class') || '';
|
|
36
|
+
if (cls && !hasHover) {
|
|
37
|
+
results.push({
|
|
38
|
+
ruleId: 'in-002',
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
title: 'No hover state style detected',
|
|
41
|
+
message: `Styled interactive elements found but no :hover or :focus rules.`,
|
|
42
|
+
element: '<style>',
|
|
43
|
+
recommendation: 'Add :hover (darken/lighten background) and :focus (outline) pseudo-classes.',
|
|
44
|
+
});
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return results;
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'in-003',
|
|
53
|
+
title: 'Forms should have a submit mechanism',
|
|
54
|
+
check: ($) => {
|
|
55
|
+
const results = [];
|
|
56
|
+
$('form').each((_i, el) => {
|
|
57
|
+
const $el = $(el);
|
|
58
|
+
const hasSubmitBtn = $el.find('button[type="submit"], input[type="submit"]').length > 0;
|
|
59
|
+
const hasOnSubmit = $el.attr('onsubmit');
|
|
60
|
+
if (!hasSubmitBtn && !hasOnSubmit) {
|
|
61
|
+
results.push({
|
|
62
|
+
ruleId: 'in-003',
|
|
63
|
+
severity: 'error',
|
|
64
|
+
title: 'Form lacks submit button',
|
|
65
|
+
message: 'A <form> element has no submit button nor onsubmit handler.',
|
|
66
|
+
element: buildSelector($el),
|
|
67
|
+
recommendation: 'Add <button type="submit"> or handle form submission via onsubmit.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return results;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'in-004',
|
|
76
|
+
title: 'Links should have an href or be disabled',
|
|
77
|
+
check: ($) => {
|
|
78
|
+
const results = [];
|
|
79
|
+
$('a').each((_i, el) => {
|
|
80
|
+
const $el = $(el);
|
|
81
|
+
const href = $el.attr('href');
|
|
82
|
+
if (!href || href === '#') {
|
|
83
|
+
results.push({
|
|
84
|
+
ruleId: 'in-004',
|
|
85
|
+
severity: 'warning',
|
|
86
|
+
title: 'Link with no destination',
|
|
87
|
+
message: `<a> has href="${href || '(missing)'}" — clicking does nothing.`,
|
|
88
|
+
element: buildSelector($el),
|
|
89
|
+
recommendation: 'Remove the <a> wrapper, add a real href, or use a <button> instead.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return results;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'in-005',
|
|
98
|
+
title: 'Disabled buttons should have an explanation',
|
|
99
|
+
check: ($) => {
|
|
100
|
+
const results = [];
|
|
101
|
+
$('button:disabled, input:disabled, [aria-disabled="true"]').each((_i, el) => {
|
|
102
|
+
const $el = $(el);
|
|
103
|
+
const hasHint = $el.attr('title') || $el.attr('aria-label');
|
|
104
|
+
const adjacentHint = $el.next('.hint, .help, small').length > 0;
|
|
105
|
+
if (!hasHint && !adjacentHint) {
|
|
106
|
+
results.push({
|
|
107
|
+
ruleId: 'in-005',
|
|
108
|
+
severity: 'info',
|
|
109
|
+
title: 'Disabled element without explanation',
|
|
110
|
+
message: `Element is disabled but users may not know why.`,
|
|
111
|
+
element: buildSelector($el),
|
|
112
|
+
recommendation: 'Add a title attribute or a visible hint explaining why it is disabled.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return results;
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
function buildSelector($el) {
|
|
121
|
+
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
122
|
+
const id = $el.attr?.('id');
|
|
123
|
+
if (id)
|
|
124
|
+
return `#${id}`;
|
|
125
|
+
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
126
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
127
|
+
}
|
|
128
|
+
export function inspectInteraction(html) {
|
|
129
|
+
const $ = load(html);
|
|
130
|
+
const results = [];
|
|
131
|
+
for (const rule of rules) {
|
|
132
|
+
results.push(...rule.check($));
|
|
133
|
+
}
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
export const interactionMeta = {
|
|
137
|
+
category,
|
|
138
|
+
label: 'Interaction logic',
|
|
139
|
+
description: 'Behavior — hover effects, form handling, link targets, disabled states',
|
|
140
|
+
ruleCount: rules.length,
|
|
141
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
const category = 'responsive';
|
|
3
|
+
const rules = [
|
|
4
|
+
{
|
|
5
|
+
id: 'rp-001',
|
|
6
|
+
title: 'Avoid fixed pixel widths on containers',
|
|
7
|
+
check: ($) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
$('div, section, header, footer, main, aside, nav').each((_i, el) => {
|
|
10
|
+
const style = $(el).attr('style') || '';
|
|
11
|
+
const match = style.match(/width\s*:\s*(\d+)px/);
|
|
12
|
+
if (match && parseInt(match[1]) > 400) {
|
|
13
|
+
results.push({
|
|
14
|
+
ruleId: 'rp-001',
|
|
15
|
+
severity: 'error',
|
|
16
|
+
title: 'Fixed-width container breaks on small screens',
|
|
17
|
+
message: `Width: ${match[1]}px — will overflow on smaller viewports.`,
|
|
18
|
+
element: buildSelector($(el)),
|
|
19
|
+
recommendation: 'Use max-width + width: 100% instead, or use CSS clamp() for fluid sizing.',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return results;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'rp-002',
|
|
28
|
+
title: 'Page needs viewport meta tag for mobile scaling',
|
|
29
|
+
check: ($) => {
|
|
30
|
+
const results = [];
|
|
31
|
+
const viewport = $('meta[name="viewport"]');
|
|
32
|
+
if (viewport.length === 0) {
|
|
33
|
+
results.push({
|
|
34
|
+
ruleId: 'rp-002',
|
|
35
|
+
severity: 'error',
|
|
36
|
+
title: 'Missing viewport <meta> tag',
|
|
37
|
+
message: 'Without viewport meta, mobile browsers may scale the page wrongly.',
|
|
38
|
+
element: '<head>',
|
|
39
|
+
recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> to <head>.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const content = viewport.attr('content') || '';
|
|
44
|
+
if (!content.includes('width=device-width')) {
|
|
45
|
+
results.push({
|
|
46
|
+
ruleId: 'rp-002',
|
|
47
|
+
severity: 'warning',
|
|
48
|
+
title: 'Viewport meta should use width=device-width',
|
|
49
|
+
message: `Current content: "${content}"`,
|
|
50
|
+
element: 'meta[name="viewport"]',
|
|
51
|
+
recommendation: 'Change to: content="width=device-width, initial-scale=1"',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'rp-003',
|
|
60
|
+
title: 'No responsive styles detected',
|
|
61
|
+
check: ($) => {
|
|
62
|
+
const results = [];
|
|
63
|
+
const html = $.html();
|
|
64
|
+
const hasMQ = /@media\s*\(/i.test(html);
|
|
65
|
+
if (!hasMQ) {
|
|
66
|
+
results.push({
|
|
67
|
+
ruleId: 'rp-003',
|
|
68
|
+
severity: 'warning',
|
|
69
|
+
title: 'No CSS media queries found',
|
|
70
|
+
message: 'No @media rules detected — unlikely to be responsive.',
|
|
71
|
+
element: '<style>',
|
|
72
|
+
recommendation: 'Add at least a mobile-first breakpoint (e.g. @media (max-width: 768px)) to adjust layout.',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'rp-004',
|
|
80
|
+
title: 'Fixed-column grid without responsive fallback',
|
|
81
|
+
check: ($) => {
|
|
82
|
+
const results = [];
|
|
83
|
+
const styleTags = $('style').toArray();
|
|
84
|
+
for (const tag of styleTags) {
|
|
85
|
+
const css = $(tag).html() || '';
|
|
86
|
+
const gridMatch = css.match(/grid-template-columns\s*:\s*repeat\s*\((\d+)/);
|
|
87
|
+
if (gridMatch) {
|
|
88
|
+
const cols = parseInt(gridMatch[1]);
|
|
89
|
+
if (cols >= 3 && !css.includes('@media')) {
|
|
90
|
+
results.push({
|
|
91
|
+
ruleId: 'rp-004',
|
|
92
|
+
severity: 'warning',
|
|
93
|
+
title: 'Multi-column grid without responsive breakpoint',
|
|
94
|
+
message: `${cols}-column grid detected but no @media rule to collapse on mobile.`,
|
|
95
|
+
element: '<style>',
|
|
96
|
+
recommendation: 'Add @media (max-width: 768px) to stack columns vertically.',
|
|
97
|
+
});
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return results;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'rp-005',
|
|
107
|
+
title: 'Small touch targets on mobile',
|
|
108
|
+
check: ($) => {
|
|
109
|
+
const results = [];
|
|
110
|
+
$('button, a, [role="button"]').each((_i, el) => {
|
|
111
|
+
const style = $(el).attr('style') || '';
|
|
112
|
+
const sizeMatch = style.match(/(?:width|height)\s*:\s*(\d+)px/);
|
|
113
|
+
if (sizeMatch && parseInt(sizeMatch[1]) < 44) {
|
|
114
|
+
results.push({
|
|
115
|
+
ruleId: 'rp-005',
|
|
116
|
+
severity: 'warning',
|
|
117
|
+
title: 'Touch target below recommended minimum',
|
|
118
|
+
message: `Element is ${sizeMatch[1]}px — WCAG recommends 44x44px minimum.`,
|
|
119
|
+
element: buildSelector($(el)),
|
|
120
|
+
recommendation: 'Increase size to at least 44x44px, or add sufficient padding.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return results;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
function buildSelector($el) {
|
|
129
|
+
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
130
|
+
const id = $el.attr?.('id');
|
|
131
|
+
if (id)
|
|
132
|
+
return `#${id}`;
|
|
133
|
+
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
134
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
135
|
+
}
|
|
136
|
+
export function inspectResponsive(html) {
|
|
137
|
+
const $ = load(html);
|
|
138
|
+
const results = [];
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
results.push(...rule.check($));
|
|
141
|
+
}
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
export const responsiveMeta = {
|
|
145
|
+
category,
|
|
146
|
+
label: 'Responsive',
|
|
147
|
+
description: 'Mobile-readiness — fixed widths, viewport, media queries, touch targets',
|
|
148
|
+
ruleCount: rules.length,
|
|
149
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function severityIcon(sev) {
|
|
2
|
+
switch (sev) {
|
|
3
|
+
case 'error': return '[X]';
|
|
4
|
+
case 'warning': return '[!]';
|
|
5
|
+
case 'info': return '[i]';
|
|
6
|
+
default: return '[?]';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function formatDetection(d) {
|
|
10
|
+
const lines = [
|
|
11
|
+
` ${severityIcon(d.severity)} ${d.ruleId}: ${d.title}`,
|
|
12
|
+
` ${d.message}`,
|
|
13
|
+
];
|
|
14
|
+
if (d.element) {
|
|
15
|
+
lines.push(` At: ${d.element}`);
|
|
16
|
+
}
|
|
17
|
+
lines.push(` Fix: ${d.recommendation}`);
|
|
18
|
+
return lines.join('\n');
|
|
19
|
+
}
|
|
20
|
+
const catLabels = {
|
|
21
|
+
aesthetic: 'Aesthetic',
|
|
22
|
+
a11y: 'Accessibility',
|
|
23
|
+
responsive: 'Responsive',
|
|
24
|
+
interaction: 'Interaction',
|
|
25
|
+
};
|
|
26
|
+
export function generateTextReport(report) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
lines.push('━'.repeat(60));
|
|
29
|
+
lines.push(' FE Inspector — Frontend Quality Report');
|
|
30
|
+
lines.push('━'.repeat(60));
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push(` Generated: ${report.timestamp}`);
|
|
33
|
+
if (report.url)
|
|
34
|
+
lines.push(` Page: ${report.url}`);
|
|
35
|
+
lines.push(` Elements: ${report.totalElements}`);
|
|
36
|
+
lines.push('');
|
|
37
|
+
lines.push(` Summary: ${report.summary}`);
|
|
38
|
+
lines.push('');
|
|
39
|
+
const cats = Object.entries(report.categories)
|
|
40
|
+
.filter(([_, detections]) => detections.length > 0);
|
|
41
|
+
if (cats.length === 0) {
|
|
42
|
+
lines.push(' ✓ All good! No issues found across any category.');
|
|
43
|
+
lines.push('');
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
for (const [cat, detections] of cats) {
|
|
47
|
+
if (detections.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
lines.push(` ── ${catLabels[cat] || cat} (${detections.length}) ──`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
for (const d of detections) {
|
|
52
|
+
lines.push(formatDetection(d));
|
|
53
|
+
lines.push('');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Score summary
|
|
57
|
+
const score = Math.max(0, Math.min(100, report.passed === 0 && report.errors === 0
|
|
58
|
+
? 50
|
|
59
|
+
: Math.round((report.passed / (report.passed + report.warnings + report.errors)) * 100)));
|
|
60
|
+
lines.push('━'.repeat(60));
|
|
61
|
+
lines.push(` Quality Score: ${score}/100`);
|
|
62
|
+
lines.push(` ${report.passed} passed | ${report.warnings} warnings | ${report.errors} errors`);
|
|
63
|
+
lines.push('━'.repeat(60));
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
export function generateJsonReport(report) {
|
|
67
|
+
return JSON.stringify(report, null, 2);
|
|
68
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
2
|
+
export interface Detection {
|
|
3
|
+
ruleId: string;
|
|
4
|
+
severity: Severity;
|
|
5
|
+
title: string;
|
|
6
|
+
message: string;
|
|
7
|
+
element?: string;
|
|
8
|
+
recommendation: string;
|
|
9
|
+
}
|
|
10
|
+
export type InspectCategory = 'aesthetic' | 'a11y' | 'responsive' | 'interaction';
|
|
11
|
+
export interface InspectReport {
|
|
12
|
+
url?: string;
|
|
13
|
+
totalElements: number;
|
|
14
|
+
passed: number;
|
|
15
|
+
warnings: number;
|
|
16
|
+
errors: number;
|
|
17
|
+
categories: Record<InspectCategory, Detection[]>;
|
|
18
|
+
summary: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
}
|
|
21
|
+
export interface InspectInput {
|
|
22
|
+
html: string;
|
|
23
|
+
url?: string;
|
|
24
|
+
categories?: InspectCategory[];
|
|
25
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fe-inspector-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP Server for frontend quality inspection — catches aesthetic flaws, a11y issues, responsive breaks, and interaction bugs in AI-generated HTML.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fe-inspect": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"rules",
|
|
12
|
+
"skills"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"mcp-server",
|
|
23
|
+
"frontend",
|
|
24
|
+
"quality",
|
|
25
|
+
"inspection",
|
|
26
|
+
"hallmark",
|
|
27
|
+
"a11y",
|
|
28
|
+
"responsive",
|
|
29
|
+
"agent-skills"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
34
|
+
"cheerio": "^1.0.0",
|
|
35
|
+
"jsdom": "^25.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/cheerio": "^0.22.35",
|
|
39
|
+
"@types/node": "^22.20.1",
|
|
40
|
+
"tsx": "^4.19.0",
|
|
41
|
+
"typescript": "^5.7.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fe-inspect
|
|
3
|
+
description: Frontend quality inspection — catches aesthetic flaws, a11y issues, responsive breaks, and interaction bugs in AI-generated HTML. Run after any UI generation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FE Inspect Skill
|
|
7
|
+
|
|
8
|
+
Use this skill after generating HTML to verify quality across four dimensions.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
After the agent generates HTML output, ask it to inspect:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Use the fe-inspect MCP tools to check the HTML I just generated.
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or trigger manually in a Claude Code / Codex session:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Run fe-inspect on the most recent HTML output and report issues.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What it checks
|
|
25
|
+
|
|
26
|
+
| Category | What it catches |
|
|
27
|
+
|----------|----------------|
|
|
28
|
+
| aesthetic | Purple-blue gradients, pill-shaped elements, low contrast text, cramped spacing, stacked-card layouts |
|
|
29
|
+
| a11y | Missing alt text, unlabelled buttons, form inputs without labels, positive tabindex, clickable divs without roles |
|
|
30
|
+
| responsive | Fixed pixel widths, missing viewport meta, no media queries, rigid grids, small touch targets |
|
|
31
|
+
| interaction | Missing pointer cursor, no hover/focus feedback, forms without submit, empty links, disabled without explanation |
|
|
32
|
+
|
|
33
|
+
## MCP tools
|
|
34
|
+
|
|
35
|
+
- `inspect_html` — run all checks on HTML, get a full report
|
|
36
|
+
- `inspect_html_fix` — run all checks, get structured fix suggestions the agent can apply
|
|
37
|
+
- `inspect_categories` — list available categories and rule counts
|
|
38
|
+
|
|
39
|
+
## Design principles
|
|
40
|
+
|
|
41
|
+
1. **hallmark covered aesthetic only.** This skill extends to behavior-level checks that hallmark ignores.
|
|
42
|
+
2. **Report is actionable.** Every detection includes a specific fix recommendation.
|
|
43
|
+
3. **Not a style guide.** It detects anti-patterns, not taste choices.
|