@astralkit/mcp 1.7.1 → 1.9.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/CHANGELOG.md +27 -0
- package/README.md +8 -2
- package/dist/api.js +2 -2
- package/dist/audit.d.ts +22 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +402 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth.d.ts +18 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +21 -13
- package/dist/auth.js.map +1 -1
- package/dist/browser.d.ts +1686 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +51 -0
- package/dist/browser.js.map +1 -0
- package/dist/capture.d.ts +38 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +84 -0
- package/dist/capture.js.map +1 -0
- package/dist/data/art-direction.d.ts +2 -0
- package/dist/data/art-direction.d.ts.map +1 -0
- package/dist/data/art-direction.js +316 -0
- package/dist/data/art-direction.js.map +1 -0
- package/dist/data/crosswalk.d.ts +1 -1
- package/dist/data/crosswalk.d.ts.map +1 -1
- package/dist/data/crosswalk.js +135 -2
- package/dist/data/crosswalk.js.map +1 -1
- package/dist/data/polish.d.ts.map +1 -1
- package/dist/data/polish.js +28 -8
- package/dist/data/polish.js.map +1 -1
- package/dist/data/rules.d.ts +1 -1
- package/dist/data/rules.d.ts.map +1 -1
- package/dist/data/rules.js +55 -3
- package/dist/data/rules.js.map +1 -1
- package/dist/data/screens.d.ts.map +1 -1
- package/dist/data/screens.js +36 -4
- package/dist/data/screens.js.map +1 -1
- package/dist/data/theming.d.ts +2 -0
- package/dist/data/theming.d.ts.map +1 -0
- package/dist/data/theming.js +71 -0
- package/dist/data/theming.js.map +1 -0
- package/dist/data/visual.d.ts +7 -0
- package/dist/data/visual.d.ts.map +1 -0
- package/dist/data/visual.js +130 -0
- package/dist/data/visual.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +590 -44
- package/dist/server.js.map +1 -1
- package/package.json +6 -4
package/dist/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { configure, listComponents, getComponent, getCategories, listBoosters, getBooster, ApiError } from './api.js';
|
|
5
|
-
import {
|
|
5
|
+
import { validateAccess, canAccessBoosters, AuthError } from './auth.js';
|
|
6
6
|
import { DESIGN_TOKENS } from './data/tokens.js';
|
|
7
7
|
import { CODING_STANDARDS } from './data/rules.js';
|
|
8
8
|
import { PHOSPHOR_ICONS, PHOSPHOR_ICON_NAMES } from './data/icons.js';
|
|
@@ -11,8 +11,13 @@ import { CROSSWALK, nearestToken } from './data/crosswalk.js';
|
|
|
11
11
|
import { buildPolishGuide, ARCHETYPE_KEYS } from './data/polish.js';
|
|
12
12
|
import { buildStandardsGuide, BUILD_STANDARD_TOPICS } from './data/build-standards.js';
|
|
13
13
|
import { buildScreenBlueprint, SCREEN_TYPES } from './data/screens.js';
|
|
14
|
+
import { buildArtDirection } from './data/art-direction.js';
|
|
15
|
+
import { THEMING } from './data/theming.js';
|
|
14
16
|
import { reviewApp } from './review.js';
|
|
15
|
-
|
|
17
|
+
import { captureScreenshot, BrowserUnavailableError, CaptureError, VIEWPORTS } from './capture.js';
|
|
18
|
+
import { auditPage } from './audit.js';
|
|
19
|
+
import { buildVisualVerdictGuide, buildVisualSelfReviewGuide, buildBrowserFallbackGuide } from './data/visual.js';
|
|
20
|
+
const VERSION = '1.9.0';
|
|
16
21
|
/** Build "raw → ak" suggestions for a list of flagged raw classes (for validate_code). */
|
|
17
22
|
function tokenSuggestions(classes) {
|
|
18
23
|
const seen = new Set();
|
|
@@ -29,29 +34,25 @@ function tokenSuggestions(classes) {
|
|
|
29
34
|
}
|
|
30
35
|
// The golden path — surfaced as server `instructions` so even a naive agent
|
|
31
36
|
// (one that just gets "build X") self-guides to design-system-quality output.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
BUILD IT RIGHT (architecture & screen UX): get_build_standards (atomic design, separation of concerns, the loading/empty/error/populated state contract, error handling, responsive/mobile-menu, radix; pass a topic). For a specific screen call get_screen_blueprint(screen_type) — it tells you WHICH AstralKit components to install for that screen + the must-haves. Audit existing/AI-generated code with review_app.
|
|
53
|
-
|
|
54
|
-
Hard rules: build from library components, never freestyle; 16px body floor (text-ak-base); 48px touch targets; Phosphor icons only; semantic color tokens; PRESERVE the app's theme. Prefer the resources astralkit://tokens, ://rules, ://icons, ://setup, ://standards if your client supports them.`;
|
|
37
|
+
//
|
|
38
|
+
// ⚠ KEEP THIS SHORT. MCP clients TRUNCATE long server instructions — the
|
|
39
|
+
// 2026-08-23 bakeoff proved an agent never saw the theming/verify rules because
|
|
40
|
+
// they sat below the truncation point. Detail lives in tool RESPONSES (which
|
|
41
|
+
// always arrive intact): search_components.nextSteps, recipe.afterInstall,
|
|
42
|
+
// install_component.instructions, validate_code.summary.
|
|
43
|
+
const SERVER_INSTRUCTIONS = `AstralKit builds polished, accessible, on-brand UI. The LIBRARY is the source of truth: NEVER freestyle a component the library provides — search, install, re-content.
|
|
44
|
+
|
|
45
|
+
GOLDEN PATH for ANY UI request:
|
|
46
|
+
1. get_coding_standards + get_design_tokens (ak-* tokens ONLY; no raw Tailwind colors, no arbitrary values).
|
|
47
|
+
2. get_theming — pick one of the 16 palettes to match the brand; shipping the bare black-and-white default is a FAILURE.
|
|
48
|
+
3. get_screen_blueprint(type) for dashboards/nav/auth/settings/onboarding/pricing/marketing.
|
|
49
|
+
4. search_components PER REGION → get_preview → get_component (recipe) → install_component → RUN the command → RE-CONTENT (swap copy/nav/logo/data; keep structure and polish). A real screen composes SEVERAL installed components.
|
|
50
|
+
5. validate_code + build (tsc + next build) — validate_code is not a compiler.
|
|
51
|
+
6. VERIFY (never skip, never declare done without it): screenshot at desktop AND tablet(768) AND mobile(390); use clickSelector to OPEN every dropdown/modal/menu and check overlay surfaces (bg-ak-elevated + border + shadow-lg); run audit_page (FREE, no key needed — reflow, contrast, tap targets, font-size histogram); re-query the recipe and diff feature-by-feature for anything dropped (mobile menu, click-outside, keyboard nav). Fix, re-capture, iterate (max 3 rounds).
|
|
52
|
+
|
|
53
|
+
Every tool response carries its own nextSteps — FOLLOW them. Sizing: you have a trained bias toward tiny text/icons; body floor 16px, meaningful icons 20-24px; when uncertain size UP. Preserve the app theme (dark stays dark). Phosphor icons only. If imagery is needed, get_art_direction + your image tools; search_logos for brand marks.
|
|
54
|
+
|
|
55
|
+
ACCESS: free tier = standards, tokens, theming, search/previews, validate_code, blueprints, art direction, audit_page + free components. Pro key (ASTRALKIT_API_KEY) unlocks polish_ui, review_app, verify_visual, screenshot_ui, premium components. If a premium tool declines, continue with the free tools — audit_page ALWAYS runs — and never fabricate a declined tool's output.`;
|
|
55
56
|
// ─── Security: Input validation ───────────────────────────────────────────────
|
|
56
57
|
const MAX_QUERY_LENGTH = 200;
|
|
57
58
|
const MAX_SLUG_LENGTH = 100;
|
|
@@ -65,6 +66,21 @@ function validateSlug(slug) {
|
|
|
65
66
|
return 'Invalid slug format. Slugs contain only lowercase letters, numbers, and hyphens.';
|
|
66
67
|
return null;
|
|
67
68
|
}
|
|
69
|
+
const MAX_URL_LENGTH = 2_000;
|
|
70
|
+
function validatePageUrl(url) {
|
|
71
|
+
if (url.length > MAX_URL_LENGTH)
|
|
72
|
+
return 'URL too long (max 2000 characters).';
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
76
|
+
return 'Only http:// and https:// URLs can be captured.';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return 'Invalid URL. Pass an absolute URL, e.g. http://localhost:3000/dashboard.';
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
68
84
|
// ─── Security: Periodic auth revalidation ─────────────────────────────────────
|
|
69
85
|
const REVALIDATION_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
|
70
86
|
// During a transient network outage, keep serving on the last good validation
|
|
@@ -75,26 +91,36 @@ class AuthGuard {
|
|
|
75
91
|
lastCheck;
|
|
76
92
|
apiKey;
|
|
77
93
|
revoked = false;
|
|
78
|
-
|
|
94
|
+
current;
|
|
95
|
+
constructor(apiKey, initial) {
|
|
79
96
|
this.apiKey = apiKey;
|
|
97
|
+
this.current = initial;
|
|
80
98
|
this.lastCheck = Date.now();
|
|
81
99
|
}
|
|
100
|
+
get auth() {
|
|
101
|
+
return this.current;
|
|
102
|
+
}
|
|
82
103
|
async check() {
|
|
104
|
+
// Anonymous sessions have no key to revalidate — free surface only.
|
|
105
|
+
if (!this.apiKey)
|
|
106
|
+
return;
|
|
83
107
|
if (this.revoked) {
|
|
84
|
-
throw new AuthError('Your AstralKit
|
|
108
|
+
throw new AuthError('Your AstralKit API key is no longer valid.\n' +
|
|
85
109
|
'Restart the MCP server after renewing at https://astralkit.com/pricing');
|
|
86
110
|
}
|
|
87
111
|
if (Date.now() - this.lastCheck < REVALIDATION_INTERVAL_MS)
|
|
88
112
|
return;
|
|
89
113
|
try {
|
|
90
|
-
|
|
114
|
+
// Plan changes (upgrade or downgrade) take effect on the next
|
|
115
|
+
// revalidation without a restart.
|
|
116
|
+
this.current = await validateAccess(this.apiKey);
|
|
91
117
|
this.lastCheck = Date.now();
|
|
92
118
|
}
|
|
93
119
|
catch (err) {
|
|
94
120
|
if (err instanceof AuthError) {
|
|
95
|
-
// Genuine auth failure (invalid key
|
|
121
|
+
// Genuine auth failure (invalid key) — revoke immediately.
|
|
96
122
|
this.revoked = true;
|
|
97
|
-
console.error('[astralkit-mcp]
|
|
123
|
+
console.error('[astralkit-mcp] Key revalidation failed — access revoked.');
|
|
98
124
|
throw err;
|
|
99
125
|
}
|
|
100
126
|
// Transient (network/timeout, ApiError). Do NOT revoke: keep serving on the
|
|
@@ -106,6 +132,21 @@ class AuthGuard {
|
|
|
106
132
|
throw err;
|
|
107
133
|
}
|
|
108
134
|
}
|
|
135
|
+
/** Gate for premium-only tools (polish_ui, review_app, verify_visual, screenshot_ui). */
|
|
136
|
+
async requirePremium(feature) {
|
|
137
|
+
await this.check();
|
|
138
|
+
if (this.current.level === 'premium')
|
|
139
|
+
return;
|
|
140
|
+
if (this.current.level === 'anonymous') {
|
|
141
|
+
throw new AuthError(`${feature} is a premium tool.\n` +
|
|
142
|
+
'Set the ASTRALKIT_API_KEY environment variable with a Pro key and restart the MCP server.\n' +
|
|
143
|
+
'Get your key at https://astralkit.com/settings — plans at https://astralkit.com/pricing\n' +
|
|
144
|
+
'Meanwhile, continue the golden path with the free tools (search, previews, tokens, validate_code).');
|
|
145
|
+
}
|
|
146
|
+
throw new AuthError(`${feature} requires a Pro or higher subscription.\n` +
|
|
147
|
+
`Your current plan: ${this.current.tier}\n` +
|
|
148
|
+
'Upgrade at https://astralkit.com/pricing');
|
|
149
|
+
}
|
|
109
150
|
}
|
|
110
151
|
// ─── Security: API key redaction ──────────────────────────────────────────────
|
|
111
152
|
function redactApiKey(text, apiKey) {
|
|
@@ -238,6 +279,12 @@ function createServer(auth, guard, apiKey) {
|
|
|
238
279
|
frameworks: c.supported_frameworks,
|
|
239
280
|
previewImage: c.previewImage,
|
|
240
281
|
})),
|
|
282
|
+
nextSteps: [
|
|
283
|
+
'get_preview(slug) to SEE candidates, then get_component (recipe) + install_component (RUN the command) for the best fit.',
|
|
284
|
+
'A real screen usually composes SEVERAL components — search per region (shell, table, dialog, empty state), not once.',
|
|
285
|
+
'Have you applied a palette yet? get_theming lists the 16 — shipping the bare black-and-white default theme is a failure.',
|
|
286
|
+
'Building a known screen type? get_screen_blueprint(dashboard|nav|auth|settings|onboarding|pricing|marketing) first.',
|
|
287
|
+
],
|
|
241
288
|
});
|
|
242
289
|
}
|
|
243
290
|
catch (err) {
|
|
@@ -366,7 +413,14 @@ function createServer(auth, guard, apiKey) {
|
|
|
366
413
|
slug: result.slug,
|
|
367
414
|
framework: fw,
|
|
368
415
|
previewImage: result.previewImage,
|
|
369
|
-
howToAdapt:
|
|
416
|
+
howToAdapt: `INSTALL this component first: run the install_command below in your terminal (or call install_component). The component is NOT available until the command executes. Then REUSE it — keep its structure, layout, spacing, and polish (watermarks, display type, complex layout, effects ARE the premium character). ADAPT = RE-CONTENT only: replace placeholder copy, nav items, logo, and sample data with the real app content, and match the app theme (preserve dark/light — never flip it). Do NOT regenerate the layout into something generic, and do NOT freestyle a replacement. If it is not an exact fit, modify THIS recipe rather than building from a blank file. COMPLETENESS CHECK: after adapting, call get_component for this SAME slug again and compare your output against the recipe feature-by-feature. Common omissions: responsive/mobile menu (hamburger + Radix Dialog), click-outside dismiss, keyboard navigation, focus trapping, active/hover/focus states, overlay surface treatment (bg-ak-elevated + border + shadow-lg on dropdowns), animations/transitions. If the recipe has it and yours doesn't, add it — partial extraction is a failure.`,
|
|
417
|
+
afterInstall: [
|
|
418
|
+
'1. RUN the install_command — the component is NOT available until it executes.',
|
|
419
|
+
'2. RE-CONTENT: swap copy/nav/logo/data; keep structure, spacing, polish. Apply a palette (get_theming) — bare black-and-white default = unfinished.',
|
|
420
|
+
'3. VERIFY (mandatory): screenshot at desktop + tablet(768) + mobile(390); clickSelector to OPEN every dropdown/modal/menu in this component and check overlay surfaces (bg-ak-elevated + border + shadow-lg) and that nothing deforms the layout.',
|
|
421
|
+
'4. audit_page on the route (FREE, no key): reflow, contrast, tap targets, font-size histogram. Fix errors.',
|
|
422
|
+
'5. Re-fetch this recipe and diff your build against it feature-by-feature — mobile menu, click-outside dismiss, keyboard nav, hover/active states. Partial extraction is a failure.',
|
|
423
|
+
],
|
|
370
424
|
intendedFont: 'Inter via font-ak-sans (load with next/font and wire to --font-ak-sans — see get_setup)',
|
|
371
425
|
tokensUsed,
|
|
372
426
|
iconsUsed: icons.used,
|
|
@@ -385,8 +439,12 @@ function createServer(auth, guard, apiKey) {
|
|
|
385
439
|
if (err instanceof AuthError)
|
|
386
440
|
return errorResult(err.message);
|
|
387
441
|
if (err instanceof ApiError && err.code === 'PRO_REQUIRED') {
|
|
388
|
-
return errorResult(`Component "${slug}"
|
|
389
|
-
|
|
442
|
+
return errorResult(`Component "${slug}" is a premium component.\n` +
|
|
443
|
+
(guard.auth.level === 'anonymous'
|
|
444
|
+
? 'Set ASTRALKIT_API_KEY with a Pro key (https://astralkit.com/settings) and restart the MCP server.\n'
|
|
445
|
+
: '') +
|
|
446
|
+
'Upgrade at https://astralkit.com/pricing\n' +
|
|
447
|
+
'Free components remain available — search_components shows is_pro per result.');
|
|
390
448
|
}
|
|
391
449
|
return errorResult(`Failed to get component: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
392
450
|
}
|
|
@@ -415,7 +473,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
415
473
|
'font sizes/weights, and surface treatment so it looks like part of the library, while KEEPING ' +
|
|
416
474
|
'its layout, content, and app behavior. Use this (not just tokenizing) when the source design ' +
|
|
417
475
|
'is mediocre and needs design-quality elevation, not only ak-* token translation. Returns a ' +
|
|
418
|
-
'
|
|
476
|
+
'7-phase procedure, a design-smell rubric, allowed/forbidden structural changes, a self-check, ' +
|
|
419
477
|
'and per-region reference targets. Pass the UI regions you see (e.g. ["kanban-card","sidebar","form"]).',
|
|
420
478
|
inputSchema: {
|
|
421
479
|
archetypes: z.array(z.string().max(MAX_QUERY_LENGTH)).max(40).optional()
|
|
@@ -423,7 +481,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
423
481
|
},
|
|
424
482
|
}, async ({ archetypes }) => {
|
|
425
483
|
try {
|
|
426
|
-
await guard.
|
|
484
|
+
await guard.requirePremium('polish_ui');
|
|
427
485
|
}
|
|
428
486
|
catch (err) {
|
|
429
487
|
if (err instanceof AuthError)
|
|
@@ -494,6 +552,46 @@ function createServer(auth, guard, apiKey) {
|
|
|
494
552
|
}
|
|
495
553
|
return textResult(buildScreenBlueprint(screen_type));
|
|
496
554
|
});
|
|
555
|
+
server.registerTool('get_art_direction', {
|
|
556
|
+
title: 'Get Art Direction',
|
|
557
|
+
description: 'Get art direction guidance for a screen or component — what assets to generate, style/mood hints, ' +
|
|
558
|
+
'which tools to use (e.g. generate_image, generate_video, search_logos), quality gates, and anti-patterns. ' +
|
|
559
|
+
'Call this BEFORE building screens that need imagery (heroes, landing pages, dashboards with empty states, ' +
|
|
560
|
+
'marketing sections, auth pages). The MCP cannot generate assets itself, but it tells you exactly what to ' +
|
|
561
|
+
'generate with your other connected tools and how to verify the result.',
|
|
562
|
+
inputSchema: {
|
|
563
|
+
context: z.string().max(MAX_QUERY_LENGTH)
|
|
564
|
+
.describe('What you are building — e.g. "hero section for SaaS landing page", "pricing page", "dashboard", "auth login", "marketing landing page"'),
|
|
565
|
+
},
|
|
566
|
+
}, async ({ context }) => {
|
|
567
|
+
try {
|
|
568
|
+
await guard.check();
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
if (err instanceof AuthError)
|
|
572
|
+
return errorResult(err.message);
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
return textResult(buildArtDirection(context));
|
|
576
|
+
});
|
|
577
|
+
server.registerTool('get_theming', {
|
|
578
|
+
title: 'Get Theming (palette catalog)',
|
|
579
|
+
description: 'Get the 16 designed AstralKit palettes (9 light, 7 dark) with mood descriptors and the 2-step application ' +
|
|
580
|
+
'(import astralkit/palettes + data-ak-theme="<id>"). Call this for EVERY new screen/app build and pick a palette ' +
|
|
581
|
+
'that fits the brand mood — shipping the bare default black-and-white theme reads as an unfinished template. ' +
|
|
582
|
+
'One palette per app; preserve an existing app\'s light/dark mode; 60-30-10 still applies within a palette.',
|
|
583
|
+
inputSchema: {},
|
|
584
|
+
}, async () => {
|
|
585
|
+
try {
|
|
586
|
+
await guard.check();
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
if (err instanceof AuthError)
|
|
590
|
+
return errorResult(err.message);
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
return textResult(THEMING);
|
|
594
|
+
});
|
|
497
595
|
server.registerTool('get_setup', {
|
|
498
596
|
title: 'Get Setup',
|
|
499
597
|
description: 'Get the exact project setup AstralKit needs to RENDER correctly: the CSS imports (theme + utilities), ' +
|
|
@@ -595,7 +693,8 @@ function createServer(auth, guard, apiKey) {
|
|
|
595
693
|
title: 'Get Preview Image',
|
|
596
694
|
description: 'Get a rendered PREVIEW IMAGE of a component so you can SEE its design before building. ' +
|
|
597
695
|
'Use it to compare candidates from search_components and pick the best fit — ' +
|
|
598
|
-
'then call get_component (recipe) to install and reuse the chosen one. Returns the image.'
|
|
696
|
+
'then call get_component (recipe) to install and reuse the chosen one. Returns the image. ' +
|
|
697
|
+
'(After you build, close the loop with verify_visual: it screenshots YOUR render and compares it against this reference.)',
|
|
599
698
|
inputSchema: {
|
|
600
699
|
slug: z.string().max(MAX_SLUG_LENGTH).describe('Component slug to preview'),
|
|
601
700
|
},
|
|
@@ -623,6 +722,255 @@ function createServer(auth, guard, apiKey) {
|
|
|
623
722
|
return errorResult(`Failed to get preview: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
624
723
|
}
|
|
625
724
|
});
|
|
725
|
+
// ─── Visual verification (capture → compare → iterate) ───────────────
|
|
726
|
+
const viewportSchema = z.enum(Object.keys(VIEWPORTS)).optional().default('desktop')
|
|
727
|
+
.describe('Viewport to render at: "desktop" (1440x900, default), "tablet" (768x1024), or "mobile" (390x844). Run desktop first, then tablet, then mobile — the tablet pass is where late-breakpoint bugs hide (content crushed but not yet collapsed).');
|
|
728
|
+
async function tryCapture(opts) {
|
|
729
|
+
try {
|
|
730
|
+
return { ok: true, shot: await captureScreenshot(opts) };
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
if (err instanceof BrowserUnavailableError)
|
|
734
|
+
return { ok: false, failure: { kind: 'no-browser', reason: err.message } };
|
|
735
|
+
if (err instanceof CaptureError)
|
|
736
|
+
return { ok: false, failure: { kind: 'capture-error', message: err.message } };
|
|
737
|
+
throw err;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** Resolve a component's reference preview image (same non-Pro-gated path as get_preview). */
|
|
741
|
+
async function fetchReferenceImage(slug) {
|
|
742
|
+
const list = await listComponents({ search: slug, limit: 10 });
|
|
743
|
+
const url = list.components.find(c => c.slug === slug)?.previewImage;
|
|
744
|
+
if (!url)
|
|
745
|
+
return { error: `No preview available for "${slug}". Use search_components to find a valid slug.` };
|
|
746
|
+
try {
|
|
747
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(12_000) });
|
|
748
|
+
if (!res.ok)
|
|
749
|
+
return null;
|
|
750
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
751
|
+
return { base64: buf.toString('base64'), mimeType: res.headers.get('content-type') || 'image/jpeg' };
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return null; // transient fetch failure — verify without the reference rather than blocking the gate
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** Structured prerequisite prompt — returned when a browser tool can't run. */
|
|
758
|
+
function buildPrerequisite(reason) {
|
|
759
|
+
return {
|
|
760
|
+
action: 'suggest_install',
|
|
761
|
+
tool: 'playwright-core',
|
|
762
|
+
reason,
|
|
763
|
+
install_command: 'npm i -g playwright-core',
|
|
764
|
+
question: 'Install playwright-core globally for visual verification? This enables screenshot_ui, verify_visual, and audit_page to capture and audit your rendered pages.',
|
|
765
|
+
options: ['Yes, install it', 'No, I\'ll verify manually'],
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
server.registerTool('verify_visual', {
|
|
769
|
+
title: 'Verify Visual (screenshot vs reference)',
|
|
770
|
+
description: 'The FINAL quality gate for any UI work — ALWAYS call this after validate_code + build succeed, before declaring done. ' +
|
|
771
|
+
'Screenshots YOUR rendered page (needs the dev server running) and returns it NEXT TO the AstralKit reference preview ' +
|
|
772
|
+
'plus a region-by-region comparison rubric you must answer item by item. This catches what code checks cannot: a banner ' +
|
|
773
|
+
'with no image, a shrunken logo, an unintended dark background, an unloaded font, broken spacing. ' +
|
|
774
|
+
'Pass the route URL and the slug of the reference component you built from; omit slug for a reference-free self-review. ' +
|
|
775
|
+
'Fix every FAIL it surfaces and call it again — iterate until all items pass (max 3 rounds). ' +
|
|
776
|
+
'INTERACTIVE STATES: after the static capture, re-run with clickSelector for each dropdown/popover/modal trigger ' +
|
|
777
|
+
'to verify overlay surface treatment (bg-ak-elevated + border + shadow-lg). ' +
|
|
778
|
+
'RESPONSIVE: also run with viewport:"mobile" to verify mobile menu and layout.',
|
|
779
|
+
inputSchema: {
|
|
780
|
+
url: z.string().max(MAX_URL_LENGTH).describe('The page to capture — your running app route, e.g. http://localhost:3000/dashboard'),
|
|
781
|
+
slug: z.string().max(MAX_SLUG_LENGTH).optional()
|
|
782
|
+
.describe('Slug of the reference component you built from (adds its preview image to compare against). Omit if there is no single reference.'),
|
|
783
|
+
selector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
784
|
+
.describe('Optional CSS selector to capture just the rebuilt region (e.g. "main", "#hero") when it sits inside a larger app shell.'),
|
|
785
|
+
clickSelector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
786
|
+
.describe('Click this element BEFORE capturing — use to open dropdowns, popovers, or modals for interactive state verification (e.g. "[data-testid=\'account-menu\']", "button:has-text(\'Menu\')").'),
|
|
787
|
+
viewport: viewportSchema,
|
|
788
|
+
fullPage: z.boolean().optional().default(true).describe('Capture the full scrollable page (default true) or just the viewport.'),
|
|
789
|
+
},
|
|
790
|
+
}, async ({ url, slug, selector, clickSelector, viewport, fullPage }) => {
|
|
791
|
+
const urlErr = validatePageUrl(url);
|
|
792
|
+
if (urlErr)
|
|
793
|
+
return errorResult(urlErr);
|
|
794
|
+
if (slug) {
|
|
795
|
+
const slugErr = validateSlug(slug);
|
|
796
|
+
if (slugErr)
|
|
797
|
+
return errorResult(slugErr);
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
await guard.requirePremium('verify_visual');
|
|
801
|
+
let reference = null;
|
|
802
|
+
let referenceNote = '';
|
|
803
|
+
if (slug) {
|
|
804
|
+
const ref = await fetchReferenceImage(slug);
|
|
805
|
+
if (ref && 'error' in ref)
|
|
806
|
+
return errorResult(ref.error);
|
|
807
|
+
if (ref)
|
|
808
|
+
reference = ref;
|
|
809
|
+
else
|
|
810
|
+
referenceNote = `\n\n(Note: the reference preview for "${slug}" could not be fetched right now — run the rubric as a self-review against the recipe you installed.)`;
|
|
811
|
+
}
|
|
812
|
+
const result = await tryCapture({ url, selector, clickSelector, viewport, fullPage });
|
|
813
|
+
if (!result.ok) {
|
|
814
|
+
if (result.failure.kind === 'capture-error')
|
|
815
|
+
return errorResult(result.failure.message);
|
|
816
|
+
const content = [];
|
|
817
|
+
if (reference) {
|
|
818
|
+
content.push({ type: 'text', text: `REFERENCE preview ("${slug}") — the quality bar:` });
|
|
819
|
+
content.push({ type: 'image', data: reference.base64, mimeType: reference.mimeType });
|
|
820
|
+
}
|
|
821
|
+
content.push({ type: 'text', text: buildBrowserFallbackGuide(result.failure.reason, !!reference) });
|
|
822
|
+
content.push({ type: 'text', text: '\n\n---\nPREREQUISITE:\n' + JSON.stringify(buildPrerequisite(result.failure.reason), null, 2) });
|
|
823
|
+
return { content };
|
|
824
|
+
}
|
|
825
|
+
const { shot } = result;
|
|
826
|
+
const content = [];
|
|
827
|
+
if (reference) {
|
|
828
|
+
content.push({ type: 'text', text: `Image 1 of 2 — REFERENCE preview ("${slug}"), the quality bar:` });
|
|
829
|
+
content.push({ type: 'image', data: reference.base64, mimeType: reference.mimeType });
|
|
830
|
+
content.push({ type: 'text', text: `Image 2 of 2 — YOUR render (${shot.scope}, ${shot.viewport} viewport):` });
|
|
831
|
+
content.push({ type: 'image', data: shot.base64, mimeType: shot.mimeType });
|
|
832
|
+
content.push({ type: 'text', text: buildVisualVerdictGuide(slug, shot.scope, shot.viewport) + referenceNote });
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
content.push({ type: 'text', text: `YOUR render (${shot.scope}, ${shot.viewport} viewport):` });
|
|
836
|
+
content.push({ type: 'image', data: shot.base64, mimeType: shot.mimeType });
|
|
837
|
+
content.push({ type: 'text', text: buildVisualSelfReviewGuide(shot.scope, shot.viewport) + referenceNote });
|
|
838
|
+
}
|
|
839
|
+
return { content };
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
if (err instanceof AuthError)
|
|
843
|
+
return errorResult(err.message);
|
|
844
|
+
return errorResult(`Visual verification failed: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
server.registerTool('screenshot_ui', {
|
|
848
|
+
title: 'Screenshot UI (see your own render)',
|
|
849
|
+
description: 'Screenshot a page of YOUR running app (dev server must be up) and get the image back with a visual self-review ' +
|
|
850
|
+
'rubric — so you can SEE what you actually rendered instead of assuming. Use during polish/revamp work or any time ' +
|
|
851
|
+
'you changed UI without a single reference component. When you built from a specific library component, prefer ' +
|
|
852
|
+
'verify_visual (it adds the reference preview to compare against). ' +
|
|
853
|
+
'INTERACTIVE STATES: pass clickSelector to click a trigger element BEFORE the screenshot — use this to open dropdowns, ' +
|
|
854
|
+
'popovers, and modals so you can verify their styling (bg-ak-elevated + border + shadow-lg). Run once without clickSelector ' +
|
|
855
|
+
'for the static state, then again WITH clickSelector for each interactive element.',
|
|
856
|
+
inputSchema: {
|
|
857
|
+
url: z.string().max(MAX_URL_LENGTH).describe('The page to capture, e.g. http://localhost:3000/settings'),
|
|
858
|
+
selector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
859
|
+
.describe('Optional CSS selector to capture one region (e.g. "main", "#pricing").'),
|
|
860
|
+
clickSelector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
861
|
+
.describe('Click this element BEFORE capturing — use to open dropdowns, popovers, or modals for interactive state verification (e.g. "[data-testid=\'account-menu\']", "button:has-text(\'Menu\')").'),
|
|
862
|
+
viewport: viewportSchema,
|
|
863
|
+
fullPage: z.boolean().optional().default(true).describe('Capture the full scrollable page (default true) or just the viewport.'),
|
|
864
|
+
},
|
|
865
|
+
}, async ({ url, selector, clickSelector, viewport, fullPage }) => {
|
|
866
|
+
const urlErr = validatePageUrl(url);
|
|
867
|
+
if (urlErr)
|
|
868
|
+
return errorResult(urlErr);
|
|
869
|
+
try {
|
|
870
|
+
await guard.requirePremium('screenshot_ui');
|
|
871
|
+
const result = await tryCapture({ url, selector, clickSelector, viewport, fullPage });
|
|
872
|
+
if (!result.ok) {
|
|
873
|
+
if (result.failure.kind === 'capture-error')
|
|
874
|
+
return errorResult(result.failure.message);
|
|
875
|
+
return {
|
|
876
|
+
content: [
|
|
877
|
+
{ type: 'text', text: buildBrowserFallbackGuide(result.failure.reason, false) },
|
|
878
|
+
{ type: 'text', text: '\n\n---\nPREREQUISITE:\n' + JSON.stringify(buildPrerequisite(result.failure.reason), null, 2) },
|
|
879
|
+
],
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const { shot } = result;
|
|
883
|
+
return {
|
|
884
|
+
content: [
|
|
885
|
+
{ type: 'text', text: `YOUR render (${shot.scope}, ${shot.viewport} viewport):` },
|
|
886
|
+
{ type: 'image', data: shot.base64, mimeType: shot.mimeType },
|
|
887
|
+
{ type: 'text', text: buildVisualSelfReviewGuide(shot.scope, shot.viewport) },
|
|
888
|
+
],
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
if (err instanceof AuthError)
|
|
893
|
+
return errorResult(err.message);
|
|
894
|
+
return errorResult(`Screenshot failed: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
// ─── Numerical audit (shipyard-grade quality gate) ───────────────────
|
|
898
|
+
server.registerTool('audit_page', {
|
|
899
|
+
title: 'Audit Page (numerical quality gate)',
|
|
900
|
+
description: 'Run a shipyard-grade numerical audit on your rendered page (dev server must be running). ' +
|
|
901
|
+
'Tests 8 checks at multiple breakpoints: horizontal reflow, tap targets (WCAG 2.5.8), prose line measure, ' +
|
|
902
|
+
'document structure (h1, landmarks, heading levels), focus visibility (:focus-visible), ' +
|
|
903
|
+
'motion/prefers-reduced-motion, CLS risk (unsized media), and WCAG color contrast (with alpha compositing ' +
|
|
904
|
+
'through the DOM tree — not a naive check). Returns structured findings with error/warning/note counts. ' +
|
|
905
|
+
'This is the numerical companion to verify_visual/screenshot_ui — it measures, they show. ' +
|
|
906
|
+
'FREE TIER — always available, no Pro key needed. Run it on every route before declaring done.',
|
|
907
|
+
inputSchema: {
|
|
908
|
+
url: z.string().max(MAX_URL_LENGTH).describe('The page to audit, e.g. http://localhost:3000/dashboard'),
|
|
909
|
+
breakpoints: z.array(z.number().int().min(280).max(3840)).optional()
|
|
910
|
+
.describe('Viewport widths to test (default: [390, 768, 1440]). Reflow + tap targets run at each; page-level checks run at the widest.'),
|
|
911
|
+
},
|
|
912
|
+
}, async ({ url, breakpoints }) => {
|
|
913
|
+
const urlErr = validatePageUrl(url);
|
|
914
|
+
if (urlErr)
|
|
915
|
+
return errorResult(urlErr);
|
|
916
|
+
try {
|
|
917
|
+
await guard.check();
|
|
918
|
+
const result = await auditPage(url, breakpoints ?? undefined);
|
|
919
|
+
const lines = [
|
|
920
|
+
`# Numerical Audit — ${url}`,
|
|
921
|
+
`Breakpoints: ${(breakpoints ?? [390, 768, 1440]).join(', ')}px`,
|
|
922
|
+
'',
|
|
923
|
+
];
|
|
924
|
+
for (const bp of result.breakpoints) {
|
|
925
|
+
const errs = bp.findings.filter(f => f.severity === 'error');
|
|
926
|
+
const warns = bp.findings.filter(f => f.severity === 'warning');
|
|
927
|
+
lines.push(`## ${bp.width}px`);
|
|
928
|
+
for (const f of bp.findings) {
|
|
929
|
+
const icon = f.severity === 'error' ? '✗' : f.severity === 'warning' ? '!' : '·';
|
|
930
|
+
lines.push(` ${icon} [${f.check}] ${f.detail}`);
|
|
931
|
+
if (f.elements?.length) {
|
|
932
|
+
lines.push(` offenders: ${f.elements.join(', ')}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (!errs.length && !warns.length)
|
|
936
|
+
lines.push(' ✓ All checks pass');
|
|
937
|
+
lines.push('');
|
|
938
|
+
}
|
|
939
|
+
if (result.pageLevel.length) {
|
|
940
|
+
lines.push('## Page-level checks');
|
|
941
|
+
for (const f of result.pageLevel) {
|
|
942
|
+
const icon = f.severity === 'error' ? '✗' : f.severity === 'warning' ? '!' : '·';
|
|
943
|
+
lines.push(` ${icon} [${f.check}] ${f.detail}`);
|
|
944
|
+
}
|
|
945
|
+
lines.push('');
|
|
946
|
+
}
|
|
947
|
+
lines.push(`---`);
|
|
948
|
+
lines.push(`**${result.summary.errors} errors · ${result.summary.warnings} warnings · ${result.summary.notes} notes**`);
|
|
949
|
+
if (result.summary.errors === 0 && result.summary.warnings === 0) {
|
|
950
|
+
lines.push('');
|
|
951
|
+
lines.push('Numbers pass. Now read the fold cold at 390px: what is this, who is it for,');
|
|
952
|
+
lines.push('what do I do next. No audit catches that one.');
|
|
953
|
+
}
|
|
954
|
+
else if (result.summary.errors > 0) {
|
|
955
|
+
lines.push('');
|
|
956
|
+
lines.push('Fix every ERROR before declaring done. Address WARNINGs where feasible.');
|
|
957
|
+
}
|
|
958
|
+
return textResult(lines.join('\n'));
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
if (err instanceof AuthError)
|
|
962
|
+
return errorResult(err.message);
|
|
963
|
+
if (err instanceof BrowserUnavailableError) {
|
|
964
|
+
return {
|
|
965
|
+
content: [
|
|
966
|
+
{ type: 'text', text: `Numerical audit requires a local browser but none was found: ${err.message}\n\nThe audit checks (reflow, contrast, tap targets, focus, motion, CLS, structure) could not run. This gate was SKIPPED, not passed.` },
|
|
967
|
+
{ type: 'text', text: '\n\n---\nPREREQUISITE:\n' + JSON.stringify(buildPrerequisite(err.message), null, 2) },
|
|
968
|
+
],
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return errorResult(`Audit failed: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
626
974
|
server.registerTool('install_component', {
|
|
627
975
|
title: 'Install Component',
|
|
628
976
|
description: 'Get the CLI command to install an AstralKit component into the user\'s project. ' +
|
|
@@ -650,7 +998,15 @@ function createServer(auth, guard, apiKey) {
|
|
|
650
998
|
return jsonResult({
|
|
651
999
|
slug,
|
|
652
1000
|
command,
|
|
653
|
-
|
|
1001
|
+
mustExecute: true,
|
|
1002
|
+
note: 'This returns the command string. You MUST execute it in the project root. The component is NOT installed until the command runs successfully.',
|
|
1003
|
+
instructions: [
|
|
1004
|
+
`1. RUN this command in the project root: ${command}`,
|
|
1005
|
+
'2. VERIFY the component files were created (check the output or list the installed path)',
|
|
1006
|
+
'3. Import and use the installed component — do NOT hand-write a copy of the component code',
|
|
1007
|
+
'4. Apply a palette (get_theming) if none is set — the bare black-and-white default theme reads unfinished',
|
|
1008
|
+
'5. AFTER wiring: screenshot desktop + tablet + mobile, clickSelector every dropdown/modal open state, and run audit_page (free) — installed components can still break in YOUR app (missing deps, layout collisions, unthemed overlays)',
|
|
1009
|
+
],
|
|
654
1010
|
});
|
|
655
1011
|
});
|
|
656
1012
|
server.registerTool('validate_code', {
|
|
@@ -672,6 +1028,26 @@ function createServer(auth, guard, apiKey) {
|
|
|
672
1028
|
}
|
|
673
1029
|
// Each issue carries an actionable fix (a "diff" the agent can apply directly).
|
|
674
1030
|
const issues = [];
|
|
1031
|
+
// CSS custom property references — the #1 tokenization mistake. Agents write
|
|
1032
|
+
// var(--color-ak-*) into stylesheets instead of using ak-* utility classes.
|
|
1033
|
+
const cssVarRefs = code.match(/var\(--(?:color|spacing|radius|text|tracking|leading|shadow|size|height|width)-ak-[a-z0-9_-]+\)/g);
|
|
1034
|
+
if (cssVarRefs) {
|
|
1035
|
+
issues.push({
|
|
1036
|
+
rule: 'css-var-instead-of-class',
|
|
1037
|
+
detail: `CSS custom property references found: ${[...new Set(cssVarRefs)].slice(0, 5).join(', ')}.`,
|
|
1038
|
+
fix: 'NEVER use var(--color-ak-*) / var(--spacing-ak-*) in CSS. Use the Tailwind utility class on the element instead: var(--color-ak-surface) → className="bg-ak-surface", var(--spacing-ak-3) → className="p-ak-3". Delete any stylesheet rules that reference ak CSS variables and move the styling to className.',
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
// Bare colors: bg-white, bg-black, text-white, text-black (no shade suffix)
|
|
1042
|
+
const bareColors = code.match(/\b(bg|text|border|ring)-(white|black)\b/g);
|
|
1043
|
+
if (bareColors) {
|
|
1044
|
+
const unique = [...new Set(bareColors)];
|
|
1045
|
+
issues.push({
|
|
1046
|
+
rule: 'bare-color',
|
|
1047
|
+
detail: `Bare colors: ${unique.join(', ')}.`,
|
|
1048
|
+
fix: `Replace with semantic tokens — ${tokenSuggestions(unique)}. (Full crosswalk via get_design_tokens.)`,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
675
1051
|
const rawTailwindColors = code.match(/\b(bg|text|border|ring|fill|stroke|from|to|via|divide|outline)-(gray|slate|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g);
|
|
676
1052
|
if (rawTailwindColors) {
|
|
677
1053
|
issues.push({
|
|
@@ -724,6 +1100,130 @@ function createServer(auth, guard, apiKey) {
|
|
|
724
1100
|
});
|
|
725
1101
|
}
|
|
726
1102
|
}
|
|
1103
|
+
// Shadows on panels/cards (not modals/overlays)
|
|
1104
|
+
const shadowClasses = code.match(/\bshadow-(sm|md|lg|xl|2xl)\b/g);
|
|
1105
|
+
if (shadowClasses) {
|
|
1106
|
+
const hasModal = /modal|dialog|overlay|popover|dropdown|tooltip|Menu|Sheet/i.test(code);
|
|
1107
|
+
if (!hasModal) {
|
|
1108
|
+
issues.push({
|
|
1109
|
+
rule: 'shadow-on-panel',
|
|
1110
|
+
detail: `Shadow classes found: ${[...new Set(shadowClasses)].join(', ')}.`,
|
|
1111
|
+
fix: 'Panels and cards use border border-ak-border, NOT shadow. Shadows are only allowed on modals, popovers, dropdowns, and tooltips (floating/overlaid elements).',
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// !important — never
|
|
1116
|
+
if (/!important/.test(code)) {
|
|
1117
|
+
issues.push({
|
|
1118
|
+
rule: 'important-used',
|
|
1119
|
+
detail: '!important found in code.',
|
|
1120
|
+
fix: 'Never use !important — it creates cascading specificity issues. Fix the root cause: reorder classes, restructure the component, or check for conflicting stylesheets.',
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// @apply directives
|
|
1124
|
+
if (/@apply\s/.test(code)) {
|
|
1125
|
+
issues.push({
|
|
1126
|
+
rule: 'at-apply',
|
|
1127
|
+
detail: '@apply directive found.',
|
|
1128
|
+
fix: 'Extract the utilities from @apply, convert each to its ak-* equivalent, apply them to the JSX className, and delete the CSS rule. All styling belongs on the element, never in @apply.',
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
// Dot notation in ak-* spacing (ak-1.5 silently collapses to zero in v4)
|
|
1132
|
+
const dotNotation = code.match(/\bak-\d+\.\d+\b/g);
|
|
1133
|
+
if (dotNotation) {
|
|
1134
|
+
issues.push({
|
|
1135
|
+
rule: 'dot-notation-spacing',
|
|
1136
|
+
detail: `Dot notation in ak-* tokens: ${[...new Set(dotNotation)].join(', ')}.`,
|
|
1137
|
+
fix: 'Use underscore, not dot: ak-1_5 (not ak-1.5), ak-0_5 (not ak-0.5). Dot notation silently collapses to zero in Tailwind v4.',
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
// /opacity on ak-* tokens (renders transparent, not faded)
|
|
1141
|
+
const opacityOnAk = code.match(/\b(?:bg|text|border|ring)-ak-[a-z0-9_-]+\/\d+\b/g);
|
|
1142
|
+
if (opacityOnAk) {
|
|
1143
|
+
const nonExempt = opacityOnAk.filter(m => !m.includes('inverse-on-surface'));
|
|
1144
|
+
if (nonExempt.length > 0) {
|
|
1145
|
+
issues.push({
|
|
1146
|
+
rule: 'opacity-on-ak-token',
|
|
1147
|
+
detail: `Opacity modifier on ak-* tokens: ${[...new Set(nonExempt)].slice(0, 5).join(', ')}.`,
|
|
1148
|
+
fix: 'Opacity on ak-* tokens renders transparent, not faded. Use the -subtle variant instead (e.g. bg-ak-warning-subtle not bg-ak-warning/30). The ONLY sanctioned opacity idiom is inverse-on-surface/NN.',
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// The AI sizing bias — tiny type/icons are the single most common AI-coder
|
|
1153
|
+
// failure. Static tells here; audit_page measures the rendered truth.
|
|
1154
|
+
{
|
|
1155
|
+
const tinyText = code.match(/\btext-ak-2xs\b|\btext-\[(?:[1-9]|1[01])(?:px|\.\d+px)\]/g);
|
|
1156
|
+
if (tinyText) {
|
|
1157
|
+
issues.push({
|
|
1158
|
+
rule: 'tiny-text',
|
|
1159
|
+
detail: `Sub-12px text: ${[...new Set(tinyText)].join(', ')}.`,
|
|
1160
|
+
fix: 'Text under 12px is below the accessible floor — legitimate ONLY for dataviz tick labels and legal fine print. Body = text-ak-base (16px), meta = text-ak-sm (14px), annotations = text-ak-xs (12px). You have a trained bias toward small sizes; when uncertain, size UP one step.',
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
const rawXs = code.match(/\btext-xs\b/g);
|
|
1164
|
+
if (rawXs && rawXs.length > 2) {
|
|
1165
|
+
issues.push({
|
|
1166
|
+
rule: 'small-text-density',
|
|
1167
|
+
detail: `text-xs used ${rawXs.length}× — the page skews small.`,
|
|
1168
|
+
fix: 'Heavy text-xs use is the AI sizing bias (training data skews to dense 12-14px UI). Rebuild hierarchy from the TOP down: set the display/title sizes first, body lands at text-ak-base (16px), and only tabular annotations stay at 12px.',
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
const tinyIcons = code.match(/\bsize=\{(?:1[0-4])\}/g);
|
|
1172
|
+
if (tinyIcons) {
|
|
1173
|
+
issues.push({
|
|
1174
|
+
rule: 'tiny-icon',
|
|
1175
|
+
detail: `Icons at 14px or below: ${[...new Set(tinyIcons)].join(', ')}.`,
|
|
1176
|
+
fix: 'Meaningful icons (nav items, list leading icons, section markers) belong at 20-24px. 16px only for inline chevrons and meta glyphs. size={12} or size={14} on a standalone icon reads as clutter next to text.',
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// CSS module imports
|
|
1181
|
+
if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.css['"]/g.test(code)) {
|
|
1182
|
+
issues.push({
|
|
1183
|
+
rule: 'css-module-import',
|
|
1184
|
+
detail: 'CSS module import found.',
|
|
1185
|
+
fix: 'Treat CSS modules like any stylesheet: READ the .module.css file, EXTRACT each CSS property, FIND the equivalent ak-* utility class, APPLY to className, DELETE the .module.css file, and remove the import + all styles.* references.',
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
// Overlay/dropdown surface treatment — the exact gap that caused the topbar dropdown bug.
|
|
1189
|
+
// Detect overlay-like patterns (absolute/fixed + z-index + state-driven visibility) and
|
|
1190
|
+
// check they have complete surface treatment (bg + border + shadow).
|
|
1191
|
+
{
|
|
1192
|
+
const hasOverlayPositioning = /\b(absolute|fixed)\b[^`"'\n]{0,80}\bz-\d|\bz-\d[^`"'\n]{0,80}\b(absolute|fixed)\b/.test(code);
|
|
1193
|
+
const hasDropdownState = /(isOpen|showMenu|showDropdown|menuOpen|dropdownOpen|setShow|setOpen|isVisible|showPanel|panelOpen)/i.test(code);
|
|
1194
|
+
const hasRadixOverlay = /(Popover\.Content|DropdownMenu\.Content|Dialog\.Content|HoverCard\.Content|Tooltip\.Content|Select\.Content)/i.test(code);
|
|
1195
|
+
if ((hasOverlayPositioning && hasDropdownState) || hasRadixOverlay) {
|
|
1196
|
+
const hasElevatedBg = /bg-ak-elevated/.test(code);
|
|
1197
|
+
const hasShadow = /shadow-(sm|md|lg|xl|2xl)\b/.test(code);
|
|
1198
|
+
const missing = [];
|
|
1199
|
+
if (!hasElevatedBg)
|
|
1200
|
+
missing.push('bg-ak-elevated');
|
|
1201
|
+
if (!hasShadow)
|
|
1202
|
+
missing.push('shadow-lg');
|
|
1203
|
+
if (missing.length > 0) {
|
|
1204
|
+
issues.push({
|
|
1205
|
+
rule: 'overlay-surface',
|
|
1206
|
+
detail: `Overlay/dropdown elements detected but missing surface treatment: ${missing.join(', ')}.`,
|
|
1207
|
+
fix: 'Floating overlays (dropdowns, popovers, tooltips, menus) MUST have all three: bg-ak-elevated (background), border border-ak-border (border), and shadow-lg (shadow). Overlays are the ONE place shadows are required — they float above the page and need visual separation. This is invisible in code review but immediately obvious to users.',
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Responsive navigation — desktop-only navs are the #1 responsiveness bug.
|
|
1213
|
+
{
|
|
1214
|
+
const hasNavElement = /<nav[\s>]/i.test(code) || /\bnav\b/i.test(code);
|
|
1215
|
+
const hasMultipleLinks = ([...code.matchAll(/<(?:a|Link)\s/g)].length >= 3) ||
|
|
1216
|
+
([...code.matchAll(/href=/g)].length >= 3);
|
|
1217
|
+
const hasResponsiveHiding = /(hidden\s+(sm|md|lg|xl):flex|(sm|md|lg|xl):hidden)/.test(code);
|
|
1218
|
+
const hasMobileMenu = /(hamburger|mobile.*menu|Sheet|Drawer|sidebar.*mobile|List\s|menu.*mobile)/i.test(code);
|
|
1219
|
+
if (hasNavElement && hasMultipleLinks && !hasResponsiveHiding && !hasMobileMenu) {
|
|
1220
|
+
issues.push({
|
|
1221
|
+
rule: 'no-responsive-nav',
|
|
1222
|
+
detail: 'Navigation with links but no responsive/mobile menu pattern detected.',
|
|
1223
|
+
fix: 'Desktop nav links must be hidden on mobile (hidden md:flex) with a hamburger trigger (md:hidden) that opens a Radix Dialog side-sheet. Desktop-only navs are the most common responsiveness bug. Reference the source recipe\'s mobile handling — call get_component for the same slug and check its responsive pattern.',
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
727
1227
|
if (/bg-ak-neutral-900[^"\n]*text-white|text-white[^"\n]*bg-ak-neutral-900/.test(code)) {
|
|
728
1228
|
issues.push({
|
|
729
1229
|
rule: 'fake-inverse-panel',
|
|
@@ -813,8 +1313,8 @@ function createServer(auth, guard, apiKey) {
|
|
|
813
1313
|
issueCount: issues.length,
|
|
814
1314
|
issues,
|
|
815
1315
|
summary: issues.length === 0
|
|
816
|
-
? 'Code follows AstralKit conventions. NOTE: this checks conventions, NOT that the code compiles — always run a real build/typecheck (tsc / next build)
|
|
817
|
-
: `Found ${issues.length} issue${issues.length === 1 ? '' : 's'}. Apply each fix and re-run validate_code until clean. (validate_code checks conventions, not compilation — also run a real build/typecheck.)`,
|
|
1316
|
+
? 'Code follows AstralKit conventions. NOTE: this checks conventions, NOT that the code compiles — always run a real build/typecheck (tsc / next build). Then the visual gates: verify_visual or screenshot_ui at desktop + tablet + mobile (and clickSelector for open dropdown/modal states), AND audit_page on the route (FREE, no API key — runs even when the premium visual tools are gated). A themed screen is part of done: if you have not applied a palette (get_theming), the default black-and-white theme reads as an unfinished template.'
|
|
1317
|
+
: `Found ${issues.length} issue${issues.length === 1 ? '' : 's'}. Apply each fix and re-run validate_code until clean. (validate_code checks conventions, not compilation — also run a real build/typecheck, then verify_visual/screenshot_ui + audit_page as the final gates.)`,
|
|
818
1318
|
});
|
|
819
1319
|
});
|
|
820
1320
|
server.registerTool('review_app', {
|
|
@@ -835,7 +1335,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
835
1335
|
},
|
|
836
1336
|
}, async ({ code, files, screen_type }) => {
|
|
837
1337
|
try {
|
|
838
|
-
await guard.
|
|
1338
|
+
await guard.requirePremium('review_app');
|
|
839
1339
|
}
|
|
840
1340
|
catch (err) {
|
|
841
1341
|
if (err instanceof AuthError)
|
|
@@ -950,7 +1450,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
950
1450
|
contents: [{ uri: 'astralkit://setup', text: SETUP, mimeType: 'text/markdown' }],
|
|
951
1451
|
};
|
|
952
1452
|
});
|
|
953
|
-
server.registerResource('AstralKit Polish Guide', 'astralkit://polish', { description: 'Revamp an existing UI to AstralKit quality —
|
|
1453
|
+
server.registerResource('AstralKit Polish Guide', 'astralkit://polish', { description: 'Revamp an existing UI to AstralKit quality — 7-phase procedure, design-smell rubric, allowed structural changes, self-check (quality polish, not just token translation)', mimeType: 'text/markdown' }, async () => {
|
|
954
1454
|
await guard.check();
|
|
955
1455
|
return {
|
|
956
1456
|
contents: [{ uri: 'astralkit://polish', text: buildPolishGuide(), mimeType: 'text/markdown' }],
|
|
@@ -1018,6 +1518,40 @@ function createServer(auth, guard, apiKey) {
|
|
|
1018
1518
|
},
|
|
1019
1519
|
}],
|
|
1020
1520
|
}));
|
|
1521
|
+
server.registerPrompt('preflight', {
|
|
1522
|
+
title: 'Preflight Verification',
|
|
1523
|
+
description: 'Run the full AstralKit quality gate (tsc + build + validate_code + visual + numerical audit) before declaring UI work done.',
|
|
1524
|
+
argsSchema: {
|
|
1525
|
+
routes: z.string().max(MAX_DESCRIPTION_LENGTH).describe('Comma-separated route paths to verify, e.g. "/dashboard, /settings"'),
|
|
1526
|
+
},
|
|
1527
|
+
}, async ({ routes }) => ({
|
|
1528
|
+
messages: [{
|
|
1529
|
+
role: 'user',
|
|
1530
|
+
content: {
|
|
1531
|
+
type: 'text',
|
|
1532
|
+
text: `Run the AstralKit preflight verification gate on these routes: ${routes}\n\n` +
|
|
1533
|
+
`## The 5 gates (run in order — do not skip any):\n\n` +
|
|
1534
|
+
`### 1. TypeScript\n` +
|
|
1535
|
+
`\`npx tsc --noEmit\` — must produce zero errors.\n\n` +
|
|
1536
|
+
`### 2. Build\n` +
|
|
1537
|
+
`\`npm run build\` (or \`next build\`) — must succeed. tsc alone misses runtime issues.\n\n` +
|
|
1538
|
+
`### 3. validate_code\n` +
|
|
1539
|
+
`Call validate_code on each changed file. Fix every issue, re-run until clean.\n\n` +
|
|
1540
|
+
`### 4. Visual verification\n` +
|
|
1541
|
+
`For each route:\n` +
|
|
1542
|
+
`a. screenshot_ui at desktop viewport — run the full rubric (completeness, imagery, brand, surfaces, typography, spacing, theme, integrity, interactive-states, responsive).\n` +
|
|
1543
|
+
`b. screenshot_ui with clickSelector for each dropdown/popover/modal trigger — verify overlay surface treatment (bg-ak-elevated + border + shadow-lg).\n` +
|
|
1544
|
+
`c. screenshot_ui at mobile viewport — verify responsive menu and layout.\n` +
|
|
1545
|
+
`d. Fix every FAIL, re-shot until clean (max 3 rounds per route).\n\n` +
|
|
1546
|
+
`### 5. Numerical audit\n` +
|
|
1547
|
+
`audit_page on each route URL. This runs 8 checks at multiple breakpoints: reflow, tap targets, line measure, document structure, focus visibility, motion/reduced-motion, CLS risk, and WCAG color contrast. Errors must reach ZERO. List surviving warnings with a judgment for each.\n\n` +
|
|
1548
|
+
`## Reporting\n` +
|
|
1549
|
+
`One line per gate: PASS or FAIL + detail. Do NOT declare the work done with any gate still failing.\n` +
|
|
1550
|
+
`If audit_page or screenshot_ui is unavailable (no local browser), note it explicitly — that gate was SKIPPED, not passed.\n\n` +
|
|
1551
|
+
`Numbers pass. Now read the fold cold at 390px: what is this, who is it for, what do I do next. No audit catches that one.`,
|
|
1552
|
+
},
|
|
1553
|
+
}],
|
|
1554
|
+
}));
|
|
1021
1555
|
return server;
|
|
1022
1556
|
}
|
|
1023
1557
|
export async function startServer() {
|
|
@@ -1026,9 +1560,11 @@ export async function startServer() {
|
|
|
1026
1560
|
configure(apiKey, apiUrl);
|
|
1027
1561
|
let auth;
|
|
1028
1562
|
try {
|
|
1029
|
-
auth = await
|
|
1563
|
+
auth = await validateAccess(apiKey);
|
|
1030
1564
|
}
|
|
1031
1565
|
catch (err) {
|
|
1566
|
+
// A PRESENT-but-invalid key exits loudly — a misconfigured subscriber must
|
|
1567
|
+
// hear about it, not be silently downgraded to the free tier.
|
|
1032
1568
|
if (err instanceof AuthError) {
|
|
1033
1569
|
console.error(`\n[astralkit-mcp] ${err.message}\n`);
|
|
1034
1570
|
process.exit(1);
|
|
@@ -1037,8 +1573,18 @@ export async function startServer() {
|
|
|
1037
1573
|
console.error(`\n[astralkit-mcp] Unexpected error during authentication:\n${redactApiKey(errMsg, apiKey)}\n`);
|
|
1038
1574
|
process.exit(1);
|
|
1039
1575
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1576
|
+
if (auth.level === 'anonymous') {
|
|
1577
|
+
console.error('[astralkit-mcp] Running in FREE mode (no API key). Grammar, discovery and free components are available.\n' +
|
|
1578
|
+
'[astralkit-mcp] Premium tools + the full library need a Pro key: https://astralkit.com/settings');
|
|
1579
|
+
}
|
|
1580
|
+
else if (auth.level === 'free') {
|
|
1581
|
+
console.error(`[astralkit-mcp] Authenticated as ${auth.email} (${auth.tier} — free tier). ` +
|
|
1582
|
+
'Premium tools need a Pro plan: https://astralkit.com/pricing');
|
|
1583
|
+
}
|
|
1584
|
+
else {
|
|
1585
|
+
console.error(`[astralkit-mcp] Authenticated as ${auth.email} (${auth.tier})`);
|
|
1586
|
+
}
|
|
1587
|
+
const guard = new AuthGuard(apiKey, auth);
|
|
1042
1588
|
const server = createServer(auth, guard, apiKey);
|
|
1043
1589
|
const transport = new StdioServerTransport();
|
|
1044
1590
|
await server.connect(transport);
|