@astralkit/mcp 1.7.1 → 1.9.0

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