@astralkit/mcp 1.8.0 → 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/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 +436 -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,25 @@ 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
|
-
POLISH / REVAMP (weak hierarchy, cramped spacing, off-brand surfaces): call polish_ui FIRST. It keeps the original layout, content, and behavior. Finish with screenshot_ui (or verify_visual with a reference slug) to SEE the result before declaring done.
|
|
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
|
+
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.`;
|
|
58
56
|
// ─── Security: Input validation ───────────────────────────────────────────────
|
|
59
57
|
const MAX_QUERY_LENGTH = 200;
|
|
60
58
|
const MAX_SLUG_LENGTH = 100;
|
|
@@ -93,26 +91,36 @@ class AuthGuard {
|
|
|
93
91
|
lastCheck;
|
|
94
92
|
apiKey;
|
|
95
93
|
revoked = false;
|
|
96
|
-
|
|
94
|
+
current;
|
|
95
|
+
constructor(apiKey, initial) {
|
|
97
96
|
this.apiKey = apiKey;
|
|
97
|
+
this.current = initial;
|
|
98
98
|
this.lastCheck = Date.now();
|
|
99
99
|
}
|
|
100
|
+
get auth() {
|
|
101
|
+
return this.current;
|
|
102
|
+
}
|
|
100
103
|
async check() {
|
|
104
|
+
// Anonymous sessions have no key to revalidate — free surface only.
|
|
105
|
+
if (!this.apiKey)
|
|
106
|
+
return;
|
|
101
107
|
if (this.revoked) {
|
|
102
|
-
throw new AuthError('Your AstralKit
|
|
108
|
+
throw new AuthError('Your AstralKit API key is no longer valid.\n' +
|
|
103
109
|
'Restart the MCP server after renewing at https://astralkit.com/pricing');
|
|
104
110
|
}
|
|
105
111
|
if (Date.now() - this.lastCheck < REVALIDATION_INTERVAL_MS)
|
|
106
112
|
return;
|
|
107
113
|
try {
|
|
108
|
-
|
|
114
|
+
// Plan changes (upgrade or downgrade) take effect on the next
|
|
115
|
+
// revalidation without a restart.
|
|
116
|
+
this.current = await validateAccess(this.apiKey);
|
|
109
117
|
this.lastCheck = Date.now();
|
|
110
118
|
}
|
|
111
119
|
catch (err) {
|
|
112
120
|
if (err instanceof AuthError) {
|
|
113
|
-
// Genuine auth failure (invalid key
|
|
121
|
+
// Genuine auth failure (invalid key) — revoke immediately.
|
|
114
122
|
this.revoked = true;
|
|
115
|
-
console.error('[astralkit-mcp]
|
|
123
|
+
console.error('[astralkit-mcp] Key revalidation failed — access revoked.');
|
|
116
124
|
throw err;
|
|
117
125
|
}
|
|
118
126
|
// Transient (network/timeout, ApiError). Do NOT revoke: keep serving on the
|
|
@@ -124,6 +132,21 @@ class AuthGuard {
|
|
|
124
132
|
throw err;
|
|
125
133
|
}
|
|
126
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
|
+
}
|
|
127
150
|
}
|
|
128
151
|
// ─── Security: API key redaction ──────────────────────────────────────────────
|
|
129
152
|
function redactApiKey(text, apiKey) {
|
|
@@ -256,6 +279,12 @@ function createServer(auth, guard, apiKey) {
|
|
|
256
279
|
frameworks: c.supported_frameworks,
|
|
257
280
|
previewImage: c.previewImage,
|
|
258
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
|
+
],
|
|
259
288
|
});
|
|
260
289
|
}
|
|
261
290
|
catch (err) {
|
|
@@ -384,7 +413,14 @@ function createServer(auth, guard, apiKey) {
|
|
|
384
413
|
slug: result.slug,
|
|
385
414
|
framework: fw,
|
|
386
415
|
previewImage: result.previewImage,
|
|
387
|
-
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
|
+
],
|
|
388
424
|
intendedFont: 'Inter via font-ak-sans (load with next/font and wire to --font-ak-sans — see get_setup)',
|
|
389
425
|
tokensUsed,
|
|
390
426
|
iconsUsed: icons.used,
|
|
@@ -403,8 +439,12 @@ function createServer(auth, guard, apiKey) {
|
|
|
403
439
|
if (err instanceof AuthError)
|
|
404
440
|
return errorResult(err.message);
|
|
405
441
|
if (err instanceof ApiError && err.code === 'PRO_REQUIRED') {
|
|
406
|
-
return errorResult(`Component "${slug}"
|
|
407
|
-
|
|
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.');
|
|
408
448
|
}
|
|
409
449
|
return errorResult(`Failed to get component: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
410
450
|
}
|
|
@@ -433,7 +473,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
433
473
|
'font sizes/weights, and surface treatment so it looks like part of the library, while KEEPING ' +
|
|
434
474
|
'its layout, content, and app behavior. Use this (not just tokenizing) when the source design ' +
|
|
435
475
|
'is mediocre and needs design-quality elevation, not only ak-* token translation. Returns a ' +
|
|
436
|
-
'
|
|
476
|
+
'7-phase procedure, a design-smell rubric, allowed/forbidden structural changes, a self-check, ' +
|
|
437
477
|
'and per-region reference targets. Pass the UI regions you see (e.g. ["kanban-card","sidebar","form"]).',
|
|
438
478
|
inputSchema: {
|
|
439
479
|
archetypes: z.array(z.string().max(MAX_QUERY_LENGTH)).max(40).optional()
|
|
@@ -441,7 +481,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
441
481
|
},
|
|
442
482
|
}, async ({ archetypes }) => {
|
|
443
483
|
try {
|
|
444
|
-
await guard.
|
|
484
|
+
await guard.requirePremium('polish_ui');
|
|
445
485
|
}
|
|
446
486
|
catch (err) {
|
|
447
487
|
if (err instanceof AuthError)
|
|
@@ -512,6 +552,46 @@ function createServer(auth, guard, apiKey) {
|
|
|
512
552
|
}
|
|
513
553
|
return textResult(buildScreenBlueprint(screen_type));
|
|
514
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
|
+
});
|
|
515
595
|
server.registerTool('get_setup', {
|
|
516
596
|
title: 'Get Setup',
|
|
517
597
|
description: 'Get the exact project setup AstralKit needs to RENDER correctly: the CSS imports (theme + utilities), ' +
|
|
@@ -644,7 +724,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
644
724
|
});
|
|
645
725
|
// ─── Visual verification (capture → compare → iterate) ───────────────
|
|
646
726
|
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
|
|
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).');
|
|
648
728
|
async function tryCapture(opts) {
|
|
649
729
|
try {
|
|
650
730
|
return { ok: true, shot: await captureScreenshot(opts) };
|
|
@@ -674,6 +754,17 @@ function createServer(auth, guard, apiKey) {
|
|
|
674
754
|
return null; // transient fetch failure — verify without the reference rather than blocking the gate
|
|
675
755
|
}
|
|
676
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
|
+
}
|
|
677
768
|
server.registerTool('verify_visual', {
|
|
678
769
|
title: 'Verify Visual (screenshot vs reference)',
|
|
679
770
|
description: 'The FINAL quality gate for any UI work — ALWAYS call this after validate_code + build succeed, before declaring done. ' +
|
|
@@ -681,17 +772,22 @@ function createServer(auth, guard, apiKey) {
|
|
|
681
772
|
'plus a region-by-region comparison rubric you must answer item by item. This catches what code checks cannot: a banner ' +
|
|
682
773
|
'with no image, a shrunken logo, an unintended dark background, an unloaded font, broken spacing. ' +
|
|
683
774
|
'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).'
|
|
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.',
|
|
685
779
|
inputSchema: {
|
|
686
780
|
url: z.string().max(MAX_URL_LENGTH).describe('The page to capture — your running app route, e.g. http://localhost:3000/dashboard'),
|
|
687
781
|
slug: z.string().max(MAX_SLUG_LENGTH).optional()
|
|
688
782
|
.describe('Slug of the reference component you built from (adds its preview image to compare against). Omit if there is no single reference.'),
|
|
689
783
|
selector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
690
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\')").'),
|
|
691
787
|
viewport: viewportSchema,
|
|
692
788
|
fullPage: z.boolean().optional().default(true).describe('Capture the full scrollable page (default true) or just the viewport.'),
|
|
693
789
|
},
|
|
694
|
-
}, async ({ url, slug, selector, viewport, fullPage }) => {
|
|
790
|
+
}, async ({ url, slug, selector, clickSelector, viewport, fullPage }) => {
|
|
695
791
|
const urlErr = validatePageUrl(url);
|
|
696
792
|
if (urlErr)
|
|
697
793
|
return errorResult(urlErr);
|
|
@@ -701,7 +797,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
701
797
|
return errorResult(slugErr);
|
|
702
798
|
}
|
|
703
799
|
try {
|
|
704
|
-
await guard.
|
|
800
|
+
await guard.requirePremium('verify_visual');
|
|
705
801
|
let reference = null;
|
|
706
802
|
let referenceNote = '';
|
|
707
803
|
if (slug) {
|
|
@@ -713,7 +809,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
713
809
|
else
|
|
714
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.)`;
|
|
715
811
|
}
|
|
716
|
-
const result = await tryCapture({ url, selector, viewport, fullPage });
|
|
812
|
+
const result = await tryCapture({ url, selector, clickSelector, viewport, fullPage });
|
|
717
813
|
if (!result.ok) {
|
|
718
814
|
if (result.failure.kind === 'capture-error')
|
|
719
815
|
return errorResult(result.failure.message);
|
|
@@ -723,6 +819,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
723
819
|
content.push({ type: 'image', data: reference.base64, mimeType: reference.mimeType });
|
|
724
820
|
}
|
|
725
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) });
|
|
726
823
|
return { content };
|
|
727
824
|
}
|
|
728
825
|
const { shot } = result;
|
|
@@ -752,25 +849,35 @@ function createServer(auth, guard, apiKey) {
|
|
|
752
849
|
description: 'Screenshot a page of YOUR running app (dev server must be up) and get the image back with a visual self-review ' +
|
|
753
850
|
'rubric — so you can SEE what you actually rendered instead of assuming. Use during polish/revamp work or any time ' +
|
|
754
851
|
'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).'
|
|
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.',
|
|
756
856
|
inputSchema: {
|
|
757
857
|
url: z.string().max(MAX_URL_LENGTH).describe('The page to capture, e.g. http://localhost:3000/settings'),
|
|
758
858
|
selector: z.string().max(MAX_QUERY_LENGTH).optional()
|
|
759
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\')").'),
|
|
760
862
|
viewport: viewportSchema,
|
|
761
863
|
fullPage: z.boolean().optional().default(true).describe('Capture the full scrollable page (default true) or just the viewport.'),
|
|
762
864
|
},
|
|
763
|
-
}, async ({ url, selector, viewport, fullPage }) => {
|
|
865
|
+
}, async ({ url, selector, clickSelector, viewport, fullPage }) => {
|
|
764
866
|
const urlErr = validatePageUrl(url);
|
|
765
867
|
if (urlErr)
|
|
766
868
|
return errorResult(urlErr);
|
|
767
869
|
try {
|
|
768
|
-
await guard.
|
|
769
|
-
const result = await tryCapture({ url, selector, viewport, fullPage });
|
|
870
|
+
await guard.requirePremium('screenshot_ui');
|
|
871
|
+
const result = await tryCapture({ url, selector, clickSelector, viewport, fullPage });
|
|
770
872
|
if (!result.ok) {
|
|
771
873
|
if (result.failure.kind === 'capture-error')
|
|
772
874
|
return errorResult(result.failure.message);
|
|
773
|
-
return
|
|
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
|
+
};
|
|
774
881
|
}
|
|
775
882
|
const { shot } = result;
|
|
776
883
|
return {
|
|
@@ -787,6 +894,83 @@ function createServer(auth, guard, apiKey) {
|
|
|
787
894
|
return errorResult(`Screenshot failed: ${err instanceof Error ? redactApiKey(err.message, apiKey) : 'Unknown error'}`);
|
|
788
895
|
}
|
|
789
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
|
+
});
|
|
790
974
|
server.registerTool('install_component', {
|
|
791
975
|
title: 'Install Component',
|
|
792
976
|
description: 'Get the CLI command to install an AstralKit component into the user\'s project. ' +
|
|
@@ -814,7 +998,15 @@ function createServer(auth, guard, apiKey) {
|
|
|
814
998
|
return jsonResult({
|
|
815
999
|
slug,
|
|
816
1000
|
command,
|
|
817
|
-
|
|
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
|
+
],
|
|
818
1010
|
});
|
|
819
1011
|
});
|
|
820
1012
|
server.registerTool('validate_code', {
|
|
@@ -836,6 +1028,26 @@ function createServer(auth, guard, apiKey) {
|
|
|
836
1028
|
}
|
|
837
1029
|
// Each issue carries an actionable fix (a "diff" the agent can apply directly).
|
|
838
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
|
+
}
|
|
839
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);
|
|
840
1052
|
if (rawTailwindColors) {
|
|
841
1053
|
issues.push({
|
|
@@ -888,6 +1100,130 @@ function createServer(auth, guard, apiKey) {
|
|
|
888
1100
|
});
|
|
889
1101
|
}
|
|
890
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
|
+
}
|
|
891
1227
|
if (/bg-ak-neutral-900[^"\n]*text-white|text-white[^"\n]*bg-ak-neutral-900/.test(code)) {
|
|
892
1228
|
issues.push({
|
|
893
1229
|
rule: 'fake-inverse-panel',
|
|
@@ -977,8 +1313,8 @@ function createServer(auth, guard, apiKey) {
|
|
|
977
1313
|
issueCount: issues.length,
|
|
978
1314
|
issues,
|
|
979
1315
|
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
|
|
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.)`,
|
|
982
1318
|
});
|
|
983
1319
|
});
|
|
984
1320
|
server.registerTool('review_app', {
|
|
@@ -999,7 +1335,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
999
1335
|
},
|
|
1000
1336
|
}, async ({ code, files, screen_type }) => {
|
|
1001
1337
|
try {
|
|
1002
|
-
await guard.
|
|
1338
|
+
await guard.requirePremium('review_app');
|
|
1003
1339
|
}
|
|
1004
1340
|
catch (err) {
|
|
1005
1341
|
if (err instanceof AuthError)
|
|
@@ -1114,7 +1450,7 @@ function createServer(auth, guard, apiKey) {
|
|
|
1114
1450
|
contents: [{ uri: 'astralkit://setup', text: SETUP, mimeType: 'text/markdown' }],
|
|
1115
1451
|
};
|
|
1116
1452
|
});
|
|
1117
|
-
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 () => {
|
|
1118
1454
|
await guard.check();
|
|
1119
1455
|
return {
|
|
1120
1456
|
contents: [{ uri: 'astralkit://polish', text: buildPolishGuide(), mimeType: 'text/markdown' }],
|
|
@@ -1182,6 +1518,40 @@ function createServer(auth, guard, apiKey) {
|
|
|
1182
1518
|
},
|
|
1183
1519
|
}],
|
|
1184
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
|
+
}));
|
|
1185
1555
|
return server;
|
|
1186
1556
|
}
|
|
1187
1557
|
export async function startServer() {
|
|
@@ -1190,9 +1560,11 @@ export async function startServer() {
|
|
|
1190
1560
|
configure(apiKey, apiUrl);
|
|
1191
1561
|
let auth;
|
|
1192
1562
|
try {
|
|
1193
|
-
auth = await
|
|
1563
|
+
auth = await validateAccess(apiKey);
|
|
1194
1564
|
}
|
|
1195
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.
|
|
1196
1568
|
if (err instanceof AuthError) {
|
|
1197
1569
|
console.error(`\n[astralkit-mcp] ${err.message}\n`);
|
|
1198
1570
|
process.exit(1);
|
|
@@ -1201,8 +1573,18 @@ export async function startServer() {
|
|
|
1201
1573
|
console.error(`\n[astralkit-mcp] Unexpected error during authentication:\n${redactApiKey(errMsg, apiKey)}\n`);
|
|
1202
1574
|
process.exit(1);
|
|
1203
1575
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
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);
|
|
1206
1588
|
const server = createServer(auth, guard, apiKey);
|
|
1207
1589
|
const transport = new StdioServerTransport();
|
|
1208
1590
|
await server.connect(transport);
|