@astralkit/mcp 1.8.0 → 1.9.1

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