@astralkit/mcp 1.8.0 → 1.9.1
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 +34 -0
- 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 +7 -3
- package/dist/capture.d.ts.map +1 -1
- package/dist/capture.js +17 -53
- package/dist/capture.js.map +1 -1
- 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 +19 -6
- 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.map +1 -1
- package/dist/data/visual.js +25 -0
- package/dist/data/visual.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +459 -54
- package/dist/server.js.map +1 -1
- package/package.json +3 -2
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,10 +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';
|
|
16
19
|
import { buildVisualVerdictGuide, buildVisualSelfReviewGuide, buildBrowserFallbackGuide } from './data/visual.js';
|
|
17
|
-
const VERSION = '1.
|
|
20
|
+
const VERSION = '1.9.0';
|
|
18
21
|
/** Build "raw → ak" suggestions for a list of flagged raw classes (for validate_code). */
|
|
19
22
|
function tokenSuggestions(classes) {
|
|
20
23
|
const seen = new Set();
|
|
@@ -31,30 +34,27 @@ function tokenSuggestions(classes) {
|
|
|
31
34
|
}
|
|
32
35
|
// The golden path — surfaced as server `instructions` so even a naive agent
|
|
33
36
|
// (one that just gets "build X") self-guides to design-system-quality output.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
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.
|
|
56
|
-
|
|
57
|
-
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; never declare UI work done without the verify_visual/screenshot_ui eyes-on check. 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
|
+
CLONE RULE: "reproduce/clone X" (Cal.com, Linear…) changes WHAT you build, never HOW. Structure and content come from the target; the styling SYSTEM stays ak-* tokens + palettes. NEVER build a private variable layer (--my-*) for "branding later" — palette swap is already one attribute (get_theming). Fidelity = layout, not hex values.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
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.`;
|
|
58
58
|
// ─── Security: Input validation ───────────────────────────────────────────────
|
|
59
59
|
const MAX_QUERY_LENGTH = 200;
|
|
60
60
|
const MAX_SLUG_LENGTH = 100;
|
|
@@ -93,26 +93,36 @@ class AuthGuard {
|
|
|
93
93
|
lastCheck;
|
|
94
94
|
apiKey;
|
|
95
95
|
revoked = false;
|
|
96
|
-
|
|
96
|
+
current;
|
|
97
|
+
constructor(apiKey, initial) {
|
|
97
98
|
this.apiKey = apiKey;
|
|
99
|
+
this.current = initial;
|
|
98
100
|
this.lastCheck = Date.now();
|
|
99
101
|
}
|
|
102
|
+
get auth() {
|
|
103
|
+
return this.current;
|
|
104
|
+
}
|
|
100
105
|
async check() {
|
|
106
|
+
// Anonymous sessions have no key to revalidate — free surface only.
|
|
107
|
+
if (!this.apiKey)
|
|
108
|
+
return;
|
|
101
109
|
if (this.revoked) {
|
|
102
|
-
throw new AuthError('Your AstralKit
|
|
110
|
+
throw new AuthError('Your AstralKit API key is no longer valid.\n' +
|
|
103
111
|
'Restart the MCP server after renewing at https://astralkit.com/pricing');
|
|
104
112
|
}
|
|
105
113
|
if (Date.now() - this.lastCheck < REVALIDATION_INTERVAL_MS)
|
|
106
114
|
return;
|
|
107
115
|
try {
|
|
108
|
-
|
|
116
|
+
// Plan changes (upgrade or downgrade) take effect on the next
|
|
117
|
+
// revalidation without a restart.
|
|
118
|
+
this.current = await validateAccess(this.apiKey);
|
|
109
119
|
this.lastCheck = Date.now();
|
|
110
120
|
}
|
|
111
121
|
catch (err) {
|
|
112
122
|
if (err instanceof AuthError) {
|
|
113
|
-
// Genuine auth failure (invalid key
|
|
123
|
+
// Genuine auth failure (invalid key) — revoke immediately.
|
|
114
124
|
this.revoked = true;
|
|
115
|
-
console.error('[astralkit-mcp]
|
|
125
|
+
console.error('[astralkit-mcp] Key revalidation failed — access revoked.');
|
|
116
126
|
throw err;
|
|
117
127
|
}
|
|
118
128
|
// Transient (network/timeout, ApiError). Do NOT revoke: keep serving on the
|
|
@@ -124,6 +134,21 @@ class AuthGuard {
|
|
|
124
134
|
throw err;
|
|
125
135
|
}
|
|
126
136
|
}
|
|
137
|
+
/** Gate for premium-only tools (polish_ui, review_app, verify_visual, screenshot_ui). */
|
|
138
|
+
async requirePremium(feature) {
|
|
139
|
+
await this.check();
|
|
140
|
+
if (this.current.level === 'premium')
|
|
141
|
+
return;
|
|
142
|
+
if (this.current.level === 'anonymous') {
|
|
143
|
+
throw new AuthError(`${feature} is a premium tool.\n` +
|
|
144
|
+
'Set the ASTRALKIT_API_KEY environment variable with a Pro key and restart the MCP server.\n' +
|
|
145
|
+
'Get your key at https://astralkit.com/settings — plans at https://astralkit.com/pricing\n' +
|
|
146
|
+
'Meanwhile, continue the golden path with the free tools (search, previews, tokens, validate_code).');
|
|
147
|
+
}
|
|
148
|
+
throw new AuthError(`${feature} requires a Pro or higher subscription.\n` +
|
|
149
|
+
`Your current plan: ${this.current.tier}\n` +
|
|
150
|
+
'Upgrade at https://astralkit.com/pricing');
|
|
151
|
+
}
|
|
127
152
|
}
|
|
128
153
|
// ─── Security: API key redaction ──────────────────────────────────────────────
|
|
129
154
|
function redactApiKey(text, apiKey) {
|
|
@@ -256,6 +281,12 @@ function createServer(auth, guard, apiKey) {
|
|
|
256
281
|
frameworks: c.supported_frameworks,
|
|
257
282
|
previewImage: c.previewImage,
|
|
258
283
|
})),
|
|
284
|
+
nextSteps: [
|
|
285
|
+
'get_preview(slug) to SEE candidates, then get_component (recipe) + install_component (RUN the command) for the best fit.',
|
|
286
|
+
'A real screen usually composes SEVERAL components — search per region (shell, table, dialog, empty state), not once.',
|
|
287
|
+
'Have you applied a palette yet? get_theming lists the 16 — shipping the bare black-and-white default theme is a failure.',
|
|
288
|
+
'Building a known screen type? get_screen_blueprint(dashboard|nav|auth|settings|onboarding|pricing|marketing) first.',
|
|
289
|
+
],
|
|
259
290
|
});
|
|
260
291
|
}
|
|
261
292
|
catch (err) {
|
|
@@ -384,7 +415,14 @@ function createServer(auth, guard, apiKey) {
|
|
|
384
415
|
slug: result.slug,
|
|
385
416
|
framework: fw,
|
|
386
417
|
previewImage: result.previewImage,
|
|
387
|
-
howToAdapt:
|
|
418
|
+
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.`,
|
|
419
|
+
afterInstall: [
|
|
420
|
+
'1. RUN the install_command — the component is NOT available until it executes.',
|
|
421
|
+
'2. RE-CONTENT: swap copy/nav/logo/data; keep structure, spacing, polish. Apply a palette (get_theming) — bare black-and-white default = unfinished.',
|
|
422
|
+
'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.',
|
|
423
|
+
'4. audit_page on the route (FREE, no key): reflow, contrast, tap targets, font-size histogram. Fix errors.',
|
|
424
|
+
'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.',
|
|
425
|
+
],
|
|
388
426
|
intendedFont: 'Inter via font-ak-sans (load with next/font and wire to --font-ak-sans — see get_setup)',
|
|
389
427
|
tokensUsed,
|
|
390
428
|
iconsUsed: icons.used,
|
|
@@ -403,8 +441,12 @@ function createServer(auth, guard, apiKey) {
|
|
|
403
441
|
if (err instanceof AuthError)
|
|
404
442
|
return errorResult(err.message);
|
|
405
443
|
if (err instanceof ApiError && err.code === 'PRO_REQUIRED') {
|
|
406
|
-
return errorResult(`Component "${slug}"
|
|
407
|
-
|
|
444
|
+
return errorResult(`Component "${slug}" is a premium component.\n` +
|
|
445
|
+
(guard.auth.level === 'anonymous'
|
|
446
|
+
? 'Set ASTRALKIT_API_KEY with a Pro key (https://astralkit.com/settings) and restart the MCP server.\n'
|
|
447
|
+
: '') +
|
|
448
|
+
'Upgrade at https://astralkit.com/pricing\n' +
|
|
449
|
+
'Free components remain available — search_components shows is_pro per result.');
|
|
408
450
|
}
|
|
409
451
|
return errorResult(`Failed to get component: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
410
452
|
}
|
|
@@ -433,7 +475,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
433
475
|
'font sizes/weights, and surface treatment so it looks like part of the library, while KEEPING ' +
|
|
434
476
|
'its layout, content, and app behavior. Use this (not just tokenizing) when the source design ' +
|
|
435
477
|
'is mediocre and needs design-quality elevation, not only ak-* token translation. Returns a ' +
|
|
436
|
-
'
|
|
478
|
+
'7-phase procedure, a design-smell rubric, allowed/forbidden structural changes, a self-check, ' +
|
|
437
479
|
'and per-region reference targets. Pass the UI regions you see (e.g. ["kanban-card","sidebar","form"]).',
|
|
438
480
|
inputSchema: {
|
|
439
481
|
archetypes: z.array(z.string().max(MAX_QUERY_LENGTH)).max(40).optional()
|
|
@@ -441,7 +483,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
441
483
|
},
|
|
442
484
|
}, async ({ archetypes }) => {
|
|
443
485
|
try {
|
|
444
|
-
await guard.
|
|
486
|
+
await guard.requirePremium('polish_ui');
|
|
445
487
|
}
|
|
446
488
|
catch (err) {
|
|
447
489
|
if (err instanceof AuthError)
|
|
@@ -512,6 +554,46 @@ function createServer(auth, guard, apiKey) {
|
|
|
512
554
|
}
|
|
513
555
|
return textResult(buildScreenBlueprint(screen_type));
|
|
514
556
|
});
|
|
557
|
+
server.registerTool('get_art_direction', {
|
|
558
|
+
title: 'Get Art Direction',
|
|
559
|
+
description: 'Get art direction guidance for a screen or component — what assets to generate, style/mood hints, ' +
|
|
560
|
+
'which tools to use (e.g. generate_image, generate_video, search_logos), quality gates, and anti-patterns. ' +
|
|
561
|
+
'Call this BEFORE building screens that need imagery (heroes, landing pages, dashboards with empty states, ' +
|
|
562
|
+
'marketing sections, auth pages). The MCP cannot generate assets itself, but it tells you exactly what to ' +
|
|
563
|
+
'generate with your other connected tools and how to verify the result.',
|
|
564
|
+
inputSchema: {
|
|
565
|
+
context: z.string().max(MAX_QUERY_LENGTH)
|
|
566
|
+
.describe('What you are building — e.g. "hero section for SaaS landing page", "pricing page", "dashboard", "auth login", "marketing landing page"'),
|
|
567
|
+
},
|
|
568
|
+
}, async ({ context }) => {
|
|
569
|
+
try {
|
|
570
|
+
await guard.check();
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
if (err instanceof AuthError)
|
|
574
|
+
return errorResult(err.message);
|
|
575
|
+
throw err;
|
|
576
|
+
}
|
|
577
|
+
return textResult(buildArtDirection(context));
|
|
578
|
+
});
|
|
579
|
+
server.registerTool('get_theming', {
|
|
580
|
+
title: 'Get Theming (palette catalog)',
|
|
581
|
+
description: 'Get the 16 designed AstralKit palettes (9 light, 7 dark) with mood descriptors and the 2-step application ' +
|
|
582
|
+
'(import astralkit/palettes + data-ak-theme="<id>"). Call this for EVERY new screen/app build and pick a palette ' +
|
|
583
|
+
'that fits the brand mood — shipping the bare default black-and-white theme reads as an unfinished template. ' +
|
|
584
|
+
'One palette per app; preserve an existing app\'s light/dark mode; 60-30-10 still applies within a palette.',
|
|
585
|
+
inputSchema: {},
|
|
586
|
+
}, async () => {
|
|
587
|
+
try {
|
|
588
|
+
await guard.check();
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
if (err instanceof AuthError)
|
|
592
|
+
return errorResult(err.message);
|
|
593
|
+
throw err;
|
|
594
|
+
}
|
|
595
|
+
return textResult(THEMING);
|
|
596
|
+
});
|
|
515
597
|
server.registerTool('get_setup', {
|
|
516
598
|
title: 'Get Setup',
|
|
517
599
|
description: 'Get the exact project setup AstralKit needs to RENDER correctly: the CSS imports (theme + utilities), ' +
|
|
@@ -644,7 +726,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
644
726
|
});
|
|
645
727
|
// ─── Visual verification (capture → compare → iterate) ───────────────
|
|
646
728
|
const viewportSchema = z.enum(Object.keys(VIEWPORTS)).optional().default('desktop')
|
|
647
|
-
.describe('Viewport to render at: "desktop" (1440x900, default) or "mobile" (390x844). Run desktop first, mobile
|
|
729
|
+
.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).');
|
|
648
730
|
async function tryCapture(opts) {
|
|
649
731
|
try {
|
|
650
732
|
return { ok: true, shot: await captureScreenshot(opts) };
|
|
@@ -674,6 +756,17 @@ function createServer(auth, guard, apiKey) {
|
|
|
674
756
|
return null; // transient fetch failure — verify without the reference rather than blocking the gate
|
|
675
757
|
}
|
|
676
758
|
}
|
|
759
|
+
/** Structured prerequisite prompt — returned when a browser tool can't run. */
|
|
760
|
+
function buildPrerequisite(reason) {
|
|
761
|
+
return {
|
|
762
|
+
action: 'suggest_install',
|
|
763
|
+
tool: 'playwright-core',
|
|
764
|
+
reason,
|
|
765
|
+
install_command: 'npm i -g playwright-core',
|
|
766
|
+
question: 'Install playwright-core globally for visual verification? This enables screenshot_ui, verify_visual, and audit_page to capture and audit your rendered pages.',
|
|
767
|
+
options: ['Yes, install it', 'No, I\'ll verify manually'],
|
|
768
|
+
};
|
|
769
|
+
}
|
|
677
770
|
server.registerTool('verify_visual', {
|
|
678
771
|
title: 'Verify Visual (screenshot vs reference)',
|
|
679
772
|
description: 'The FINAL quality gate for any UI work — ALWAYS call this after validate_code + build succeed, before declaring done. ' +
|
|
@@ -681,17 +774,22 @@ function createServer(auth, guard, apiKey) {
|
|
|
681
774
|
'plus a region-by-region comparison rubric you must answer item by item. This catches what code checks cannot: a banner ' +
|
|
682
775
|
'with no image, a shrunken logo, an unintended dark background, an unloaded font, broken spacing. ' +
|
|
683
776
|
'Pass the route URL and the slug of the reference component you built from; omit slug for a reference-free self-review. ' +
|
|
684
|
-
'Fix every FAIL it surfaces and call it again — iterate until all items pass (max 3 rounds).'
|
|
777
|
+
'Fix every FAIL it surfaces and call it again — iterate until all items pass (max 3 rounds). ' +
|
|
778
|
+
'INTERACTIVE STATES: after the static capture, re-run with clickSelector for each dropdown/popover/modal trigger ' +
|
|
779
|
+
'to verify overlay surface treatment (bg-ak-elevated + border + shadow-lg). ' +
|
|
780
|
+
'RESPONSIVE: also run with viewport:"mobile" to verify mobile menu and layout.',
|
|
685
781
|
inputSchema: {
|
|
686
782
|
url: z.string().max(MAX_URL_LENGTH).describe('The page to capture — your running app route, e.g. http://localhost:3000/dashboard'),
|
|
687
783
|
slug: z.string().max(MAX_SLUG_LENGTH).optional()
|
|
688
784
|
.describe('Slug of the reference component you built from (adds its preview image to compare against). Omit if there is no single reference.'),
|
|
689
785
|
selector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
690
786
|
.describe('Optional CSS selector to capture just the rebuilt region (e.g. "main", "#hero") when it sits inside a larger app shell.'),
|
|
787
|
+
clickSelector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
788
|
+
.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\')").'),
|
|
691
789
|
viewport: viewportSchema,
|
|
692
790
|
fullPage: z.boolean().optional().default(true).describe('Capture the full scrollable page (default true) or just the viewport.'),
|
|
693
791
|
},
|
|
694
|
-
}, async ({ url, slug, selector, viewport, fullPage }) => {
|
|
792
|
+
}, async ({ url, slug, selector, clickSelector, viewport, fullPage }) => {
|
|
695
793
|
const urlErr = validatePageUrl(url);
|
|
696
794
|
if (urlErr)
|
|
697
795
|
return errorResult(urlErr);
|
|
@@ -701,7 +799,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
701
799
|
return errorResult(slugErr);
|
|
702
800
|
}
|
|
703
801
|
try {
|
|
704
|
-
await guard.
|
|
802
|
+
await guard.requirePremium('verify_visual');
|
|
705
803
|
let reference = null;
|
|
706
804
|
let referenceNote = '';
|
|
707
805
|
if (slug) {
|
|
@@ -713,7 +811,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
713
811
|
else
|
|
714
812
|
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.)`;
|
|
715
813
|
}
|
|
716
|
-
const result = await tryCapture({ url, selector, viewport, fullPage });
|
|
814
|
+
const result = await tryCapture({ url, selector, clickSelector, viewport, fullPage });
|
|
717
815
|
if (!result.ok) {
|
|
718
816
|
if (result.failure.kind === 'capture-error')
|
|
719
817
|
return errorResult(result.failure.message);
|
|
@@ -723,6 +821,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
723
821
|
content.push({ type: 'image', data: reference.base64, mimeType: reference.mimeType });
|
|
724
822
|
}
|
|
725
823
|
content.push({ type: 'text', text: buildBrowserFallbackGuide(result.failure.reason, !!reference) });
|
|
824
|
+
content.push({ type: 'text', text: '\n\n---\nPREREQUISITE:\n' + JSON.stringify(buildPrerequisite(result.failure.reason), null, 2) });
|
|
726
825
|
return { content };
|
|
727
826
|
}
|
|
728
827
|
const { shot } = result;
|
|
@@ -752,25 +851,35 @@ function createServer(auth, guard, apiKey) {
|
|
|
752
851
|
description: 'Screenshot a page of YOUR running app (dev server must be up) and get the image back with a visual self-review ' +
|
|
753
852
|
'rubric — so you can SEE what you actually rendered instead of assuming. Use during polish/revamp work or any time ' +
|
|
754
853
|
'you changed UI without a single reference component. When you built from a specific library component, prefer ' +
|
|
755
|
-
'verify_visual (it adds the reference preview to compare against).'
|
|
854
|
+
'verify_visual (it adds the reference preview to compare against). ' +
|
|
855
|
+
'INTERACTIVE STATES: pass clickSelector to click a trigger element BEFORE the screenshot — use this to open dropdowns, ' +
|
|
856
|
+
'popovers, and modals so you can verify their styling (bg-ak-elevated + border + shadow-lg). Run once without clickSelector ' +
|
|
857
|
+
'for the static state, then again WITH clickSelector for each interactive element.',
|
|
756
858
|
inputSchema: {
|
|
757
859
|
url: z.string().max(MAX_URL_LENGTH).describe('The page to capture, e.g. http://localhost:3000/settings'),
|
|
758
860
|
selector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
759
861
|
.describe('Optional CSS selector to capture one region (e.g. "main", "#pricing").'),
|
|
862
|
+
clickSelector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
863
|
+
.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\')").'),
|
|
760
864
|
viewport: viewportSchema,
|
|
761
865
|
fullPage: z.boolean().optional().default(true).describe('Capture the full scrollable page (default true) or just the viewport.'),
|
|
762
866
|
},
|
|
763
|
-
}, async ({ url, selector, viewport, fullPage }) => {
|
|
867
|
+
}, async ({ url, selector, clickSelector, viewport, fullPage }) => {
|
|
764
868
|
const urlErr = validatePageUrl(url);
|
|
765
869
|
if (urlErr)
|
|
766
870
|
return errorResult(urlErr);
|
|
767
871
|
try {
|
|
768
|
-
await guard.
|
|
769
|
-
const result = await tryCapture({ url, selector, viewport, fullPage });
|
|
872
|
+
await guard.requirePremium('screenshot_ui');
|
|
873
|
+
const result = await tryCapture({ url, selector, clickSelector, viewport, fullPage });
|
|
770
874
|
if (!result.ok) {
|
|
771
875
|
if (result.failure.kind === 'capture-error')
|
|
772
876
|
return errorResult(result.failure.message);
|
|
773
|
-
return
|
|
877
|
+
return {
|
|
878
|
+
content: [
|
|
879
|
+
{ type: 'text', text: buildBrowserFallbackGuide(result.failure.reason, false) },
|
|
880
|
+
{ type: 'text', text: '\n\n---\nPREREQUISITE:\n' + JSON.stringify(buildPrerequisite(result.failure.reason), null, 2) },
|
|
881
|
+
],
|
|
882
|
+
};
|
|
774
883
|
}
|
|
775
884
|
const { shot } = result;
|
|
776
885
|
return {
|
|
@@ -787,6 +896,83 @@ function createServer(auth, guard, apiKey) {
|
|
|
787
896
|
return errorResult(`Screenshot failed: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
788
897
|
}
|
|
789
898
|
});
|
|
899
|
+
// ─── Numerical audit (shipyard-grade quality gate) ───────────────────
|
|
900
|
+
server.registerTool('audit_page', {
|
|
901
|
+
title: 'Audit Page (numerical quality gate)',
|
|
902
|
+
description: 'Run a shipyard-grade numerical audit on your rendered page (dev server must be running). ' +
|
|
903
|
+
'Tests 8 checks at multiple breakpoints: horizontal reflow, tap targets (WCAG 2.5.8), prose line measure, ' +
|
|
904
|
+
'document structure (h1, landmarks, heading levels), focus visibility (:focus-visible), ' +
|
|
905
|
+
'motion/prefers-reduced-motion, CLS risk (unsized media), and WCAG color contrast (with alpha compositing ' +
|
|
906
|
+
'through the DOM tree — not a naive check). Returns structured findings with error/warning/note counts. ' +
|
|
907
|
+
'This is the numerical companion to verify_visual/screenshot_ui — it measures, they show. ' +
|
|
908
|
+
'FREE TIER — always available, no Pro key needed. Run it on every route before declaring done.',
|
|
909
|
+
inputSchema: {
|
|
910
|
+
url: z.string().max(MAX_URL_LENGTH).describe('The page to audit, e.g. http://localhost:3000/dashboard'),
|
|
911
|
+
breakpoints: z.array(z.number().int().min(280).max(3840)).optional()
|
|
912
|
+
.describe('Viewport widths to test (default: [390, 768, 1440]). Reflow + tap targets run at each; page-level checks run at the widest.'),
|
|
913
|
+
},
|
|
914
|
+
}, async ({ url, breakpoints }) => {
|
|
915
|
+
const urlErr = validatePageUrl(url);
|
|
916
|
+
if (urlErr)
|
|
917
|
+
return errorResult(urlErr);
|
|
918
|
+
try {
|
|
919
|
+
await guard.check();
|
|
920
|
+
const result = await auditPage(url, breakpoints ?? undefined);
|
|
921
|
+
const lines = [
|
|
922
|
+
`# Numerical Audit — ${url}`,
|
|
923
|
+
`Breakpoints: ${(breakpoints ?? [390, 768, 1440]).join(', ')}px`,
|
|
924
|
+
'',
|
|
925
|
+
];
|
|
926
|
+
for (const bp of result.breakpoints) {
|
|
927
|
+
const errs = bp.findings.filter(f => f.severity === 'error');
|
|
928
|
+
const warns = bp.findings.filter(f => f.severity === 'warning');
|
|
929
|
+
lines.push(`## ${bp.width}px`);
|
|
930
|
+
for (const f of bp.findings) {
|
|
931
|
+
const icon = f.severity === 'error' ? '✗' : f.severity === 'warning' ? '!' : '·';
|
|
932
|
+
lines.push(` ${icon} [${f.check}] ${f.detail}`);
|
|
933
|
+
if (f.elements?.length) {
|
|
934
|
+
lines.push(` offenders: ${f.elements.join(', ')}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!errs.length && !warns.length)
|
|
938
|
+
lines.push(' ✓ All checks pass');
|
|
939
|
+
lines.push('');
|
|
940
|
+
}
|
|
941
|
+
if (result.pageLevel.length) {
|
|
942
|
+
lines.push('## Page-level checks');
|
|
943
|
+
for (const f of result.pageLevel) {
|
|
944
|
+
const icon = f.severity === 'error' ? '✗' : f.severity === 'warning' ? '!' : '·';
|
|
945
|
+
lines.push(` ${icon} [${f.check}] ${f.detail}`);
|
|
946
|
+
}
|
|
947
|
+
lines.push('');
|
|
948
|
+
}
|
|
949
|
+
lines.push(`---`);
|
|
950
|
+
lines.push(`**${result.summary.errors} errors · ${result.summary.warnings} warnings · ${result.summary.notes} notes**`);
|
|
951
|
+
if (result.summary.errors === 0 && result.summary.warnings === 0) {
|
|
952
|
+
lines.push('');
|
|
953
|
+
lines.push('Numbers pass. Now read the fold cold at 390px: what is this, who is it for,');
|
|
954
|
+
lines.push('what do I do next. No audit catches that one.');
|
|
955
|
+
}
|
|
956
|
+
else if (result.summary.errors > 0) {
|
|
957
|
+
lines.push('');
|
|
958
|
+
lines.push('Fix every ERROR before declaring done. Address WARNINGs where feasible.');
|
|
959
|
+
}
|
|
960
|
+
return textResult(lines.join('\n'));
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
if (err instanceof AuthError)
|
|
964
|
+
return errorResult(err.message);
|
|
965
|
+
if (err instanceof BrowserUnavailableError) {
|
|
966
|
+
return {
|
|
967
|
+
content: [
|
|
968
|
+
{ 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.` },
|
|
969
|
+
{ type: 'text', text: '\n\n---\nPREREQUISITE:\n' + JSON.stringify(buildPrerequisite(err.message), null, 2) },
|
|
970
|
+
],
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return errorResult(`Audit failed: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
790
976
|
server.registerTool('install_component', {
|
|
791
977
|
title: 'Install Component',
|
|
792
978
|
description: 'Get the CLI command to install an AstralKit component into the user\'s project. ' +
|
|
@@ -814,7 +1000,15 @@ function createServer(auth, guard, apiKey) {
|
|
|
814
1000
|
return jsonResult({
|
|
815
1001
|
slug,
|
|
816
1002
|
command,
|
|
817
|
-
|
|
1003
|
+
mustExecute: true,
|
|
1004
|
+
note: 'This returns the command string. You MUST execute it in the project root. The component is NOT installed until the command runs successfully.',
|
|
1005
|
+
instructions: [
|
|
1006
|
+
`1. RUN this command in the project root: ${command}`,
|
|
1007
|
+
'2. VERIFY the component files were created (check the output or list the installed path)',
|
|
1008
|
+
'3. Import and use the installed component — do NOT hand-write a copy of the component code',
|
|
1009
|
+
'4. Apply a palette (get_theming) if none is set — the bare black-and-white default theme reads unfinished',
|
|
1010
|
+
'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)',
|
|
1011
|
+
],
|
|
818
1012
|
});
|
|
819
1013
|
});
|
|
820
1014
|
server.registerTool('validate_code', {
|
|
@@ -836,6 +1030,26 @@ function createServer(auth, guard, apiKey) {
|
|
|
836
1030
|
}
|
|
837
1031
|
// Each issue carries an actionable fix (a "diff" the agent can apply directly).
|
|
838
1032
|
const issues = [];
|
|
1033
|
+
// CSS custom property references — the #1 tokenization mistake. Agents write
|
|
1034
|
+
// var(--color-ak-*) into stylesheets instead of using ak-* utility classes.
|
|
1035
|
+
const cssVarRefs = code.match(/var\(--(?:color|spacing|radius|text|tracking|leading|shadow|size|height|width)-ak-[a-z0-9_-]+\)/g);
|
|
1036
|
+
if (cssVarRefs) {
|
|
1037
|
+
issues.push({
|
|
1038
|
+
rule: 'css-var-instead-of-class',
|
|
1039
|
+
detail: `CSS custom property references found: ${[...new Set(cssVarRefs)].slice(0, 5).join(', ')}.`,
|
|
1040
|
+
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.',
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
// Bare colors: bg-white, bg-black, text-white, text-black (no shade suffix)
|
|
1044
|
+
const bareColors = code.match(/\b(bg|text|border|ring)-(white|black)\b/g);
|
|
1045
|
+
if (bareColors) {
|
|
1046
|
+
const unique = [...new Set(bareColors)];
|
|
1047
|
+
issues.push({
|
|
1048
|
+
rule: 'bare-color',
|
|
1049
|
+
detail: `Bare colors: ${unique.join(', ')}.`,
|
|
1050
|
+
fix: `Replace with semantic tokens — ${tokenSuggestions(unique)}. (Full crosswalk via get_design_tokens.)`,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
839
1053
|
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);
|
|
840
1054
|
if (rawTailwindColors) {
|
|
841
1055
|
issues.push({
|
|
@@ -888,6 +1102,151 @@ function createServer(auth, guard, apiKey) {
|
|
|
888
1102
|
});
|
|
889
1103
|
}
|
|
890
1104
|
}
|
|
1105
|
+
// Shadows on panels/cards (not modals/overlays)
|
|
1106
|
+
const shadowClasses = code.match(/\bshadow-(sm|md|lg|xl|2xl)\b/g);
|
|
1107
|
+
if (shadowClasses) {
|
|
1108
|
+
const hasModal = /modal|dialog|overlay|popover|dropdown|tooltip|Menu|Sheet/i.test(code);
|
|
1109
|
+
if (!hasModal) {
|
|
1110
|
+
issues.push({
|
|
1111
|
+
rule: 'shadow-on-panel',
|
|
1112
|
+
detail: `Shadow classes found: ${[...new Set(shadowClasses)].join(', ')}.`,
|
|
1113
|
+
fix: 'Panels and cards use border border-ak-border, NOT shadow. Shadows are only allowed on modals, popovers, dropdowns, and tooltips (floating/overlaid elements).',
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// !important — never
|
|
1118
|
+
if (/!important/.test(code)) {
|
|
1119
|
+
issues.push({
|
|
1120
|
+
rule: 'important-used',
|
|
1121
|
+
detail: '!important found in code.',
|
|
1122
|
+
fix: 'Never use !important — it creates cascading specificity issues. Fix the root cause: reorder classes, restructure the component, or check for conflicting stylesheets.',
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
// @apply directives
|
|
1126
|
+
if (/@apply\s/.test(code)) {
|
|
1127
|
+
issues.push({
|
|
1128
|
+
rule: 'at-apply',
|
|
1129
|
+
detail: '@apply directive found.',
|
|
1130
|
+
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.',
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
// Dot notation in ak-* spacing (ak-1.5 silently collapses to zero in v4)
|
|
1134
|
+
const dotNotation = code.match(/\bak-\d+\.\d+\b/g);
|
|
1135
|
+
if (dotNotation) {
|
|
1136
|
+
issues.push({
|
|
1137
|
+
rule: 'dot-notation-spacing',
|
|
1138
|
+
detail: `Dot notation in ak-* tokens: ${[...new Set(dotNotation)].join(', ')}.`,
|
|
1139
|
+
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.',
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
// /opacity on ak-* tokens (renders transparent, not faded)
|
|
1143
|
+
const opacityOnAk = code.match(/\b(?:bg|text|border|ring)-ak-[a-z0-9_-]+\/\d+\b/g);
|
|
1144
|
+
if (opacityOnAk) {
|
|
1145
|
+
const nonExempt = opacityOnAk.filter(m => !m.includes('inverse-on-surface'));
|
|
1146
|
+
if (nonExempt.length > 0) {
|
|
1147
|
+
issues.push({
|
|
1148
|
+
rule: 'opacity-on-ak-token',
|
|
1149
|
+
detail: `Opacity modifier on ak-* tokens: ${[...new Set(nonExempt)].slice(0, 5).join(', ')}.`,
|
|
1150
|
+
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.',
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Parallel design system — an agent defining its own CSS variable layer
|
|
1155
|
+
// (--bk-*, --app-*…) beside the loaded ak system. Found live: a Cal.com
|
|
1156
|
+
// clone with 30 private vars + 47 hexes while astralkit was imported.
|
|
1157
|
+
{
|
|
1158
|
+
const privateVars = [...new Set((code.match(/--(?!color-ak|spacing-ak|radius-ak|text-ak|font-ak|z-ak|gradient-ak|ak-)[a-z]{2,8}-[a-z0-9-]+\s*:/g) || []).map(v => v.replace(/\s*:$/, '')))];
|
|
1159
|
+
if (privateVars.length >= 6) {
|
|
1160
|
+
issues.push({
|
|
1161
|
+
rule: 'parallel-design-system',
|
|
1162
|
+
detail: `${privateVars.length} private CSS variables defined (${privateVars.slice(0, 5).join(', ')}…) — a design system beside the ak system.`,
|
|
1163
|
+
fix: 'Do not build a private variable layer. The ak-* token system is already swappable in one move (get_theming → data-ak-theme). Map every private variable to its ak-* equivalent class and delete the layer — a parallel system gets no dark mode, no theme builder, no palette swaps.',
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
const hexes = [...new Set(code.match(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g) || [])];
|
|
1167
|
+
if (hexes.length >= 3) {
|
|
1168
|
+
issues.push({
|
|
1169
|
+
rule: 'hardcoded-hex',
|
|
1170
|
+
detail: `${hexes.length} hardcoded hex colors: ${hexes.slice(0, 6).join(', ')}…`,
|
|
1171
|
+
fix: 'Static colors belong to ak-* semantic tokens (bg-ak-surface, text-ak-danger, ak-badge--success…), never hex. Hexes are only acceptable inside genuinely data-driven values (chart series fed from data). Hardcoded hexes break dark mode, the theme builder, and every palette.',
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
// The AI sizing bias — tiny type/icons are the single most common AI-coder
|
|
1176
|
+
// failure. Static tells here; audit_page measures the rendered truth.
|
|
1177
|
+
{
|
|
1178
|
+
const tinyText = code.match(/\btext-ak-2xs\b|\btext-\[(?:[1-9]|1[01])(?:px|\.\d+px)\]/g);
|
|
1179
|
+
if (tinyText) {
|
|
1180
|
+
issues.push({
|
|
1181
|
+
rule: 'tiny-text',
|
|
1182
|
+
detail: `Sub-12px text: ${[...new Set(tinyText)].join(', ')}.`,
|
|
1183
|
+
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.',
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
const rawXs = code.match(/\btext-xs\b/g);
|
|
1187
|
+
if (rawXs && rawXs.length > 2) {
|
|
1188
|
+
issues.push({
|
|
1189
|
+
rule: 'small-text-density',
|
|
1190
|
+
detail: `text-xs used ${rawXs.length}× — the page skews small.`,
|
|
1191
|
+
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.',
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
const tinyIcons = code.match(/\bsize=\{(?:1[0-4])\}/g);
|
|
1195
|
+
if (tinyIcons) {
|
|
1196
|
+
issues.push({
|
|
1197
|
+
rule: 'tiny-icon',
|
|
1198
|
+
detail: `Icons at 14px or below: ${[...new Set(tinyIcons)].join(', ')}.`,
|
|
1199
|
+
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.',
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// CSS module imports
|
|
1204
|
+
if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.css['"]/g.test(code)) {
|
|
1205
|
+
issues.push({
|
|
1206
|
+
rule: 'css-module-import',
|
|
1207
|
+
detail: 'CSS module import found.',
|
|
1208
|
+
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.',
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
// Overlay/dropdown surface treatment — the exact gap that caused the topbar dropdown bug.
|
|
1212
|
+
// Detect overlay-like patterns (absolute/fixed + z-index + state-driven visibility) and
|
|
1213
|
+
// check they have complete surface treatment (bg + border + shadow).
|
|
1214
|
+
{
|
|
1215
|
+
const hasOverlayPositioning = /\b(absolute|fixed)\b[^`"'\n]{0,80}\bz-\d|\bz-\d[^`"'\n]{0,80}\b(absolute|fixed)\b/.test(code);
|
|
1216
|
+
const hasDropdownState = /(isOpen|showMenu|showDropdown|menuOpen|dropdownOpen|setShow|setOpen|isVisible|showPanel|panelOpen)/i.test(code);
|
|
1217
|
+
const hasRadixOverlay = /(Popover\.Content|DropdownMenu\.Content|Dialog\.Content|HoverCard\.Content|Tooltip\.Content|Select\.Content)/i.test(code);
|
|
1218
|
+
if ((hasOverlayPositioning && hasDropdownState) || hasRadixOverlay) {
|
|
1219
|
+
const hasElevatedBg = /bg-ak-elevated/.test(code);
|
|
1220
|
+
const hasShadow = /shadow-(sm|md|lg|xl|2xl)\b/.test(code);
|
|
1221
|
+
const missing = [];
|
|
1222
|
+
if (!hasElevatedBg)
|
|
1223
|
+
missing.push('bg-ak-elevated');
|
|
1224
|
+
if (!hasShadow)
|
|
1225
|
+
missing.push('shadow-lg');
|
|
1226
|
+
if (missing.length > 0) {
|
|
1227
|
+
issues.push({
|
|
1228
|
+
rule: 'overlay-surface',
|
|
1229
|
+
detail: `Overlay/dropdown elements detected but missing surface treatment: ${missing.join(', ')}.`,
|
|
1230
|
+
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.',
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// Responsive navigation — desktop-only navs are the #1 responsiveness bug.
|
|
1236
|
+
{
|
|
1237
|
+
const hasNavElement = /<nav[\s>]/i.test(code) || /\bnav\b/i.test(code);
|
|
1238
|
+
const hasMultipleLinks = ([...code.matchAll(/<(?:a|Link)\s/g)].length >= 3) ||
|
|
1239
|
+
([...code.matchAll(/href=/g)].length >= 3);
|
|
1240
|
+
const hasResponsiveHiding = /(hidden\s+(sm|md|lg|xl):flex|(sm|md|lg|xl):hidden)/.test(code);
|
|
1241
|
+
const hasMobileMenu = /(hamburger|mobile.*menu|Sheet|Drawer|sidebar.*mobile|List\s|menu.*mobile)/i.test(code);
|
|
1242
|
+
if (hasNavElement && hasMultipleLinks && !hasResponsiveHiding && !hasMobileMenu) {
|
|
1243
|
+
issues.push({
|
|
1244
|
+
rule: 'no-responsive-nav',
|
|
1245
|
+
detail: 'Navigation with links but no responsive/mobile menu pattern detected.',
|
|
1246
|
+
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.',
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
891
1250
|
if (/bg-ak-neutral-900[^"\n]*text-white|text-white[^"\n]*bg-ak-neutral-900/.test(code)) {
|
|
892
1251
|
issues.push({
|
|
893
1252
|
rule: 'fake-inverse-panel',
|
|
@@ -977,8 +1336,8 @@ function createServer(auth, guard, apiKey) {
|
|
|
977
1336
|
issueCount: issues.length,
|
|
978
1337
|
issues,
|
|
979
1338
|
summary: issues.length === 0
|
|
980
|
-
? 'Code follows AstralKit conventions. NOTE: this checks conventions, NOT that the code compiles — always run a real build/typecheck (tsc / next build). Then
|
|
981
|
-
: `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 as the final
|
|
1339
|
+
? '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.'
|
|
1340
|
+
: `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.)`,
|
|
982
1341
|
});
|
|
983
1342
|
});
|
|
984
1343
|
server.registerTool('review_app', {
|
|
@@ -999,7 +1358,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
999
1358
|
},
|
|
1000
1359
|
}, async ({ code, files, screen_type }) => {
|
|
1001
1360
|
try {
|
|
1002
|
-
await guard.
|
|
1361
|
+
await guard.requirePremium('review_app');
|
|
1003
1362
|
}
|
|
1004
1363
|
catch (err) {
|
|
1005
1364
|
if (err instanceof AuthError)
|
|
@@ -1114,7 +1473,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
1114
1473
|
contents: [{ uri: 'astralkit://setup', text: SETUP, mimeType: 'text/markdown' }],
|
|
1115
1474
|
};
|
|
1116
1475
|
});
|
|
1117
|
-
server.registerResource('AstralKit Polish Guide', 'astralkit://polish', { description: 'Revamp an existing UI to AstralKit quality —
|
|
1476
|
+
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 () => {
|
|
1118
1477
|
await guard.check();
|
|
1119
1478
|
return {
|
|
1120
1479
|
contents: [{ uri: 'astralkit://polish', text: buildPolishGuide(), mimeType: 'text/markdown' }],
|
|
@@ -1182,6 +1541,40 @@ function createServer(auth, guard, apiKey) {
|
|
|
1182
1541
|
},
|
|
1183
1542
|
}],
|
|
1184
1543
|
}));
|
|
1544
|
+
server.registerPrompt('preflight', {
|
|
1545
|
+
title: 'Preflight Verification',
|
|
1546
|
+
description: 'Run the full AstralKit quality gate (tsc + build + validate_code + visual + numerical audit) before declaring UI work done.',
|
|
1547
|
+
argsSchema: {
|
|
1548
|
+
routes: z.string().max(MAX_DESCRIPTION_LENGTH).describe('Comma-separated route paths to verify, e.g. "/dashboard, /settings"'),
|
|
1549
|
+
},
|
|
1550
|
+
}, async ({ routes }) => ({
|
|
1551
|
+
messages: [{
|
|
1552
|
+
role: 'user',
|
|
1553
|
+
content: {
|
|
1554
|
+
type: 'text',
|
|
1555
|
+
text: `Run the AstralKit preflight verification gate on these routes: ${routes}\n\n` +
|
|
1556
|
+
`## The 5 gates (run in order — do not skip any):\n\n` +
|
|
1557
|
+
`### 1. TypeScript\n` +
|
|
1558
|
+
`\`npx tsc --noEmit\` — must produce zero errors.\n\n` +
|
|
1559
|
+
`### 2. Build\n` +
|
|
1560
|
+
`\`npm run build\` (or \`next build\`) — must succeed. tsc alone misses runtime issues.\n\n` +
|
|
1561
|
+
`### 3. validate_code\n` +
|
|
1562
|
+
`Call validate_code on each changed file. Fix every issue, re-run until clean.\n\n` +
|
|
1563
|
+
`### 4. Visual verification\n` +
|
|
1564
|
+
`For each route:\n` +
|
|
1565
|
+
`a. screenshot_ui at desktop viewport — run the full rubric (completeness, imagery, brand, surfaces, typography, spacing, theme, integrity, interactive-states, responsive).\n` +
|
|
1566
|
+
`b. screenshot_ui with clickSelector for each dropdown/popover/modal trigger — verify overlay surface treatment (bg-ak-elevated + border + shadow-lg).\n` +
|
|
1567
|
+
`c. screenshot_ui at mobile viewport — verify responsive menu and layout.\n` +
|
|
1568
|
+
`d. Fix every FAIL, re-shot until clean (max 3 rounds per route).\n\n` +
|
|
1569
|
+
`### 5. Numerical audit\n` +
|
|
1570
|
+
`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` +
|
|
1571
|
+
`## Reporting\n` +
|
|
1572
|
+
`One line per gate: PASS or FAIL + detail. Do NOT declare the work done with any gate still failing.\n` +
|
|
1573
|
+
`If audit_page or screenshot_ui is unavailable (no local browser), note it explicitly — that gate was SKIPPED, not passed.\n\n` +
|
|
1574
|
+
`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.`,
|
|
1575
|
+
},
|
|
1576
|
+
}],
|
|
1577
|
+
}));
|
|
1185
1578
|
return server;
|
|
1186
1579
|
}
|
|
1187
1580
|
export async function startServer() {
|
|
@@ -1190,9 +1583,11 @@ export async function startServer() {
|
|
|
1190
1583
|
configure(apiKey, apiUrl);
|
|
1191
1584
|
let auth;
|
|
1192
1585
|
try {
|
|
1193
|
-
auth = await
|
|
1586
|
+
auth = await validateAccess(apiKey);
|
|
1194
1587
|
}
|
|
1195
1588
|
catch (err) {
|
|
1589
|
+
// A PRESENT-but-invalid key exits loudly — a misconfigured subscriber must
|
|
1590
|
+
// hear about it, not be silently downgraded to the free tier.
|
|
1196
1591
|
if (err instanceof AuthError) {
|
|
1197
1592
|
console.error(`\n[astralkit-mcp] ${err.message}\n`);
|
|
1198
1593
|
process.exit(1);
|
|
@@ -1201,8 +1596,18 @@ export async function startServer() {
|
|
|
1201
1596
|
console.error(`\n[astralkit-mcp] Unexpected error during authentication:\n${redactApiKey(errMsg, apiKey)}\n`);
|
|
1202
1597
|
process.exit(1);
|
|
1203
1598
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1599
|
+
if (auth.level === 'anonymous') {
|
|
1600
|
+
console.error('[astralkit-mcp] Running in FREE mode (no API key). Grammar, discovery and free components are available.\n' +
|
|
1601
|
+
'[astralkit-mcp] Premium tools + the full library need a Pro key: https://astralkit.com/settings');
|
|
1602
|
+
}
|
|
1603
|
+
else if (auth.level === 'free') {
|
|
1604
|
+
console.error(`[astralkit-mcp] Authenticated as ${auth.email} (${auth.tier} — free tier). ` +
|
|
1605
|
+
'Premium tools need a Pro plan: https://astralkit.com/pricing');
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
console.error(`[astralkit-mcp] Authenticated as ${auth.email} (${auth.tier})`);
|
|
1609
|
+
}
|
|
1610
|
+
const guard = new AuthGuard(apiKey, auth);
|
|
1206
1611
|
const server = createServer(auth, guard, apiKey);
|
|
1207
1612
|
const transport = new StdioServerTransport();
|
|
1208
1613
|
await server.connect(transport);
|