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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,8 @@
1
+ import type { Detection } from '../types.js';
2
+ export declare function inspectA11y(html: string): Detection[];
3
+ export declare const a11yMeta: {
4
+ category: "a11y";
5
+ label: string;
6
+ description: string;
7
+ ruleCount: number;
8
+ };
@@ -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,8 @@
1
+ import type { Detection } from '../types.js';
2
+ export declare function inspectAesthetic(html: string): Detection[];
3
+ export declare const aestheticMeta: {
4
+ category: "aesthetic";
5
+ label: string;
6
+ description: string;
7
+ ruleCount: number;
8
+ };
@@ -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,8 @@
1
+ import type { Detection } from '../types.js';
2
+ export declare function inspectInteraction(html: string): Detection[];
3
+ export declare const interactionMeta: {
4
+ category: "interaction";
5
+ label: string;
6
+ description: string;
7
+ ruleCount: number;
8
+ };
@@ -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,8 @@
1
+ import type { Detection } from '../types.js';
2
+ export declare function inspectResponsive(html: string): Detection[];
3
+ export declare const responsiveMeta: {
4
+ category: "responsive";
5
+ label: string;
6
+ description: string;
7
+ ruleCount: number;
8
+ };
@@ -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,3 @@
1
+ import type { InspectReport } from '../types.js';
2
+ export declare function generateTextReport(report: InspectReport): string;
3
+ export declare function generateJsonReport(report: InspectReport): string;
@@ -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
+ }
@@ -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.