sonance-brand-mcp 1.3.20 → 1.3.21

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.
@@ -64,24 +64,30 @@ You can see screenshots of web pages and understand their visual layout, then mo
64
64
  CRITICAL RULES
65
65
  ═══════════════════════════════════════════════════════════════════════════════
66
66
 
67
+ **FILE RULES (MOST IMPORTANT):**
68
+ 1. You may ONLY edit files that are provided in the PAGE CONTEXT section
69
+ 2. NEVER create new files - only modify existing ones shown to you
70
+ 3. The filePath in your response MUST exactly match one of the provided file paths
71
+ 4. If you cannot find the right file to edit, explain this in your response instead of creating a new file
72
+
67
73
  **ANALYSIS:**
68
- 1. CAREFULLY analyze the screenshot to understand the current visual state
69
- 2. Identify elements mentioned in the user's request
70
- 3. Understand the current styling and layout
71
- 4. Consider how changes will affect the overall design
74
+ 5. CAREFULLY analyze the screenshot to understand the current visual state
75
+ 6. Identify elements mentioned in the user's request
76
+ 7. Understand the current styling and layout
77
+ 8. Consider how changes will affect the overall design
72
78
 
73
79
  **PRESERVATION RULES:**
74
- 5. NEVER delete or remove existing content, children, or JSX elements
75
- 6. NEVER change component structure unless specifically requested
76
- 7. NEVER modify TypeScript types, imports, or exports unless necessary
77
- 8. NEVER remove data-sonance-* attributes
80
+ 9. NEVER delete or remove existing content, children, or JSX elements
81
+ 10. NEVER change component structure unless specifically requested
82
+ 11. NEVER modify TypeScript types, imports, or exports unless necessary
83
+ 12. NEVER remove data-sonance-* attributes
78
84
 
79
85
  **CHANGE RULES:**
80
- 9. Make ONLY the changes requested by the user
81
- 10. Modify the minimum amount of code necessary
82
- 11. Use semantic Tailwind classes (bg-primary, text-foreground, etc.)
83
- 12. Maintain dark mode compatibility with CSS variables
84
- 13. Keep the cn() utility for className merging
86
+ 13. Make ONLY the changes requested by the user
87
+ 14. Modify the minimum amount of code necessary
88
+ 15. Use semantic Tailwind classes (bg-primary, text-foreground, etc.)
89
+ 16. Maintain dark mode compatibility with CSS variables
90
+ 17. Keep the cn() utility for className merging
85
91
 
86
92
  **SONANCE BRAND COLORS:**
87
93
  - Charcoal: #333F48, #343D46 (primary)
@@ -188,8 +194,8 @@ export async function POST(request: Request) {
188
194
  );
189
195
  }
190
196
 
191
- // Gather page context
192
- const pageContext = gatherPageContext(pageRoute, projectRoot);
197
+ // Gather page context (including focused element files via project-wide search)
198
+ const pageContext = gatherPageContext(pageRoute, projectRoot, focusedElements);
193
199
 
194
200
  // Build user message with vision
195
201
  const messageContent: Anthropic.MessageCreateParams["messages"][0]["content"] = [];
@@ -247,13 +253,19 @@ GLOBALS.CSS (relevant theme variables):
247
253
  ${pageContext.globalsCSS.substring(0, 2000)}${pageContext.globalsCSS.length > 2000 ? "\n/* ... (truncated) */" : ""}
248
254
  \`\`\`
249
255
 
256
+ VALID FILES YOU MAY EDIT:
257
+ ${pageContext.pageFile ? `- ${pageContext.pageFile}` : ""}
258
+ ${pageContext.componentSources.map((c) => `- ${c.path}`).join("\n")}
259
+
250
260
  INSTRUCTIONS:
251
261
  1. Look at the screenshot and identify elements mentioned in the user's request
252
- 2. Review the code to understand current implementation
253
- 3. Determine which files need modifications
262
+ 2. Review the provided code to understand current implementation
263
+ 3. Choose which of the VALID FILES above need modifications
254
264
  4. Generate complete modified code for each file
255
265
  5. Provide previewCSS for immediate visual feedback
256
- 6. Return as JSON in the specified format`;
266
+ 6. Return as JSON in the specified format
267
+
268
+ CRITICAL: Only use file paths from the VALID FILES list above. Do NOT create new files.`;
257
269
 
258
270
  messageContent.push({
259
271
  type: "text",
@@ -327,6 +339,34 @@ INSTRUCTIONS:
327
339
  );
328
340
  }
329
341
 
342
+ // Build list of valid file paths
343
+ const validPaths = new Set<string>();
344
+ if (pageContext.pageFile) {
345
+ validPaths.add(pageContext.pageFile);
346
+ }
347
+ for (const comp of pageContext.componentSources) {
348
+ validPaths.add(comp.path);
349
+ }
350
+
351
+ // Validate AI response - reject any file paths not in our valid list
352
+ const invalidMods = (aiResponse.modifications || []).filter(
353
+ (mod) => !validPaths.has(mod.filePath)
354
+ );
355
+
356
+ if (invalidMods.length > 0) {
357
+ console.error(
358
+ "AI attempted to create new files:",
359
+ invalidMods.map((m) => m.filePath)
360
+ );
361
+ return NextResponse.json(
362
+ {
363
+ success: false,
364
+ error: `Cannot create new files. The following paths were not found in the project: ${invalidMods.map((m) => m.filePath).join(", ")}. Please try a more specific request targeting existing components.`,
365
+ } as VisionEditResponse,
366
+ { status: 400 }
367
+ );
368
+ }
369
+
330
370
  // Read original content and generate diffs for each modification
331
371
  const modificationsWithOriginals: VisionFileModification[] = [];
332
372
  for (const mod of aiResponse.modifications || []) {
@@ -374,12 +414,77 @@ INSTRUCTIONS:
374
414
  }
375
415
  }
376
416
 
417
+ /**
418
+ * Search for component files by name across the project
419
+ * Uses similar logic to /api/sonance-find-component
420
+ */
421
+ function findComponentFileByName(
422
+ componentName: string,
423
+ projectRoot: string
424
+ ): string | null {
425
+ const normalizedName = componentName.toLowerCase().replace(/\s+/g, "-");
426
+ const baseName = normalizedName.split("-")[0];
427
+
428
+ const SEARCH_DIRS = ["src/components", "components", "src", "app"];
429
+ const EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
430
+
431
+ function searchRecursive(
432
+ dir: string,
433
+ fileName: string,
434
+ depth = 0
435
+ ): string | null {
436
+ if (depth > 4) return null;
437
+ try {
438
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
439
+ // Check direct matches
440
+ for (const ext of EXTENSIONS) {
441
+ const match = entries.find(
442
+ (e) => e.isFile() && e.name.toLowerCase() === `${fileName}${ext}`
443
+ );
444
+ if (match) return path.join(dir, match.name);
445
+ }
446
+ // Recurse into subdirs
447
+ for (const entry of entries) {
448
+ if (
449
+ entry.isDirectory() &&
450
+ !entry.name.startsWith(".") &&
451
+ entry.name !== "node_modules"
452
+ ) {
453
+ const result = searchRecursive(
454
+ path.join(dir, entry.name),
455
+ fileName,
456
+ depth + 1
457
+ );
458
+ if (result) return result;
459
+ }
460
+ }
461
+ } catch {
462
+ /* skip unreadable */
463
+ }
464
+ return null;
465
+ }
466
+
467
+ for (const dir of SEARCH_DIRS) {
468
+ const searchDir = path.join(projectRoot, dir);
469
+ if (fs.existsSync(searchDir)) {
470
+ const result = searchRecursive(searchDir, normalizedName);
471
+ if (result) return path.relative(projectRoot, result);
472
+ if (baseName !== normalizedName) {
473
+ const baseResult = searchRecursive(searchDir, baseName);
474
+ if (baseResult) return path.relative(projectRoot, baseResult);
475
+ }
476
+ }
477
+ }
478
+ return null;
479
+ }
480
+
377
481
  /**
378
482
  * Gather context about the current page for AI analysis
379
483
  */
380
484
  function gatherPageContext(
381
485
  pageRoute: string,
382
- projectRoot: string
486
+ projectRoot: string,
487
+ focusedElements?: VisionFocusedElement[]
383
488
  ): {
384
489
  pageFile: string | null;
385
490
  pageContent: string;
@@ -412,6 +517,25 @@ function gatherPageContext(
412
517
  }
413
518
  }
414
519
 
520
+ // Also search for focused element components if provided
521
+ if (focusedElements && focusedElements.length > 0) {
522
+ for (const el of focusedElements) {
523
+ // Try to find component file by name
524
+ const foundPath = findComponentFileByName(el.name, projectRoot);
525
+ if (foundPath && !componentSources.some((c) => c.path === foundPath)) {
526
+ try {
527
+ const content = fs.readFileSync(
528
+ path.join(projectRoot, foundPath),
529
+ "utf-8"
530
+ );
531
+ componentSources.push({ path: foundPath, content });
532
+ } catch {
533
+ /* skip if unreadable */
534
+ }
535
+ }
536
+ }
537
+ }
538
+
415
539
  // Read globals.css
416
540
  let globalsCSS = "";
417
541
  const globalsPath = path.join(projectRoot, "src/app/globals.css");
@@ -11,6 +11,8 @@ interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>
11
11
  description?: string;
12
12
  /** Visual state for Storybook/Figma documentation */
13
13
  state?: CheckboxState;
14
+ /** Radix UI-style callback for checked state changes */
15
+ onCheckedChange?: (checked: boolean) => void;
14
16
  }
15
17
 
16
18
  // State styles for Storybook/Figma visualization
@@ -28,17 +30,23 @@ const getStateStyles = (state?: CheckboxState) => {
28
30
  };
29
31
 
30
32
  export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
31
- ({ className, label, description, id, state, disabled, checked, defaultChecked, onChange, style, ...props }, ref) => {
33
+ ({ className, label, description, id, state, disabled, checked, defaultChecked, onChange, onCheckedChange, style, ...props }, ref) => {
32
34
  const uniqueId = useId();
33
35
  const inputId = id || `checkbox-${uniqueId}`;
34
36
  const isDisabled = disabled || state === "disabled";
35
37
 
36
- // Determine if we're in controlled mode
37
- const isControlled = checked !== undefined || onChange !== undefined;
38
+ // Determine if we're in controlled mode (only when checked is explicitly provided)
39
+ const isControlled = checked !== undefined;
38
40
  const isCheckedForState = state === "checked";
39
41
 
42
+ // Combined change handler that supports both native onChange and Radix-style onCheckedChange
43
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
44
+ onChange?.(e);
45
+ onCheckedChange?.(e.target.checked);
46
+ };
47
+
40
48
  return (
41
- <div data-sonance-name="checkbox" className="flex items-start gap-3">
49
+ <label data-sonance-name="checkbox" className="flex items-start gap-3 cursor-pointer">
42
50
  <div className="relative flex items-center justify-center">
43
51
  <input
44
52
  type="checkbox"
@@ -46,10 +54,12 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
46
54
  ref={ref}
47
55
  disabled={isDisabled}
48
56
  style={style}
57
+ onChange={handleChange}
49
58
  {...(isControlled
50
- ? { checked: checked || isCheckedForState, onChange }
51
- : { defaultChecked: defaultChecked || isCheckedForState }
59
+ ? { checked: checked ?? isCheckedForState }
60
+ : { defaultChecked: defaultChecked ?? isCheckedForState }
52
61
  )}
62
+ readOnly={isControlled && !onChange && !onCheckedChange}
53
63
  className={cn(
54
64
  "peer h-5 w-5 shrink-0 appearance-none border border-border bg-input",
55
65
  "hover:border-border-hover",
@@ -59,7 +69,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
59
69
  "transition-colors duration-150",
60
70
  getStateStyles(state),
61
71
  className
62
- )} data-sonance-name="checkbox"
72
+ )}
63
73
  {...props}
64
74
  />
65
75
  <Check
@@ -72,19 +82,18 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
72
82
  {(label || description) && (
73
83
  <div className="flex flex-col gap-0.5">
74
84
  {label && (
75
- <label
76
- htmlFor={inputId}
77
- className="text-sm font-medium text-foreground cursor-pointer select-none"
85
+ <span
86
+ className="text-sm font-medium text-foreground select-none"
78
87
  >
79
88
  {label}
80
- </label>
89
+ </span>
81
90
  )}
82
91
  {description && (
83
92
  <p id="p-description" className="text-xs text-foreground-muted">{description}</p>
84
93
  )}
85
94
  </div>
86
95
  )}
87
- </div>
96
+ </label>
88
97
  );
89
98
  }
90
99
  );
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
4
4
  import { createPortal } from "react-dom";
5
- import { Palette, X, Copy, Check, RotateCcw, ChevronDown, Save, Loader2, AlertCircle, CheckCircle, Sun, Moon, Eye, EyeOff, Zap, Image as ImageIcon, Wand2, Scan, FileCode, Tag, Type, MousePointer, FormInput, Box, Search, Send, Sparkles, RefreshCw } from "lucide-react";
5
+ import { Palette, X, Copy, Check, RotateCcw, ChevronDown, Save, Loader2, AlertCircle, CheckCircle, Sun, Moon, Eye, EyeOff, Zap, Image as ImageIcon, Wand2, Scan, FileCode, Tag, Type, MousePointer, FormInput, Box, Search, Send, Sparkles, RefreshCw, GripHorizontal } from "lucide-react";
6
6
  import { useTheme } from "next-themes";
7
7
  import { cn } from "../../lib/utils";
8
8
  import {
@@ -118,6 +118,14 @@ export function SonanceDevTools() {
118
118
  // Track which elements were changed (for highlighting until accept/revert)
119
119
  const [changedElements, setChangedElements] = useState<VisionFocusedElement[]>([]);
120
120
 
121
+ // Drag state for movable panel
122
+ const DEVTOOLS_POSITION_KEY = "sonance-devtools-pos";
123
+ const DEFAULT_POSITION = { x: 0, y: 0 }; // Offset from default bottom-right position
124
+ const [dragPosition, setDragPosition] = useState(DEFAULT_POSITION);
125
+ const [isDragging, setIsDragging] = useState(false);
126
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
127
+ const panelRef = useRef<HTMLDivElement>(null);
128
+
121
129
  // Component-specific style overrides (for scalable, project-agnostic styling)
122
130
  // Key: component type (e.g., "card", "button-primary", "card:variant123"), Value: style overrides
123
131
  const [componentOverrides, setComponentOverrides] = useState<Record<string, ComponentStyle>>({});
@@ -236,6 +244,93 @@ export function SonanceDevTools() {
236
244
  setMounted(true);
237
245
  }, []);
238
246
 
247
+ // Load drag position from localStorage on mount
248
+ useEffect(() => {
249
+ if (!mounted) return;
250
+ try {
251
+ const saved = localStorage.getItem(DEVTOOLS_POSITION_KEY);
252
+ if (saved) {
253
+ const parsed = JSON.parse(saved);
254
+ if (typeof parsed.x === "number" && typeof parsed.y === "number") {
255
+ setDragPosition(parsed);
256
+ }
257
+ }
258
+ } catch {
259
+ // Ignore parse errors
260
+ }
261
+ }, [mounted, DEVTOOLS_POSITION_KEY]);
262
+
263
+ // Drag handlers for movable panel
264
+ const headerRef = useRef<HTMLDivElement>(null);
265
+
266
+ const handleDragStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
267
+ // Don't start drag if clicking on a button or interactive element
268
+ const target = e.target as HTMLElement;
269
+ if (target.closest('button')) return;
270
+
271
+ if (!panelRef.current || !headerRef.current) return;
272
+
273
+ // Prevent text selection during drag
274
+ e.preventDefault();
275
+
276
+ // Store the initial mouse position and current panel offset
277
+ dragOffsetRef.current = {
278
+ x: e.clientX - dragPosition.x,
279
+ y: e.clientY - dragPosition.y,
280
+ };
281
+
282
+ setIsDragging(true);
283
+ headerRef.current.setPointerCapture(e.pointerId);
284
+ }, [dragPosition]);
285
+
286
+ const handleDragMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
287
+ if (!isDragging || !panelRef.current) return;
288
+
289
+ const viewportWidth = window.innerWidth;
290
+ const viewportHeight = window.innerHeight;
291
+ const panelWidth = panelRef.current.offsetWidth;
292
+ const panelHeight = panelRef.current.offsetHeight;
293
+
294
+ // Calculate new position directly from mouse movement
295
+ const newX = e.clientX - dragOffsetRef.current.x;
296
+ const newY = e.clientY - dragOffsetRef.current.y;
297
+
298
+ // Clamp to keep panel within viewport (with 24px padding)
299
+ const padding = 24;
300
+ const maxX = viewportWidth - panelWidth - padding;
301
+ const maxY = viewportHeight - panelHeight - padding;
302
+ const minX = -(viewportWidth - panelWidth - padding);
303
+ const minY = -(viewportHeight - panelHeight - padding);
304
+
305
+ setDragPosition({
306
+ x: Math.max(minX, Math.min(maxX, newX)),
307
+ y: Math.max(minY, Math.min(maxY, newY)),
308
+ });
309
+ }, [isDragging]);
310
+
311
+ const handleDragEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
312
+ if (!isDragging || !headerRef.current) return;
313
+
314
+ setIsDragging(false);
315
+ headerRef.current.releasePointerCapture(e.pointerId);
316
+
317
+ // Save position to localStorage
318
+ try {
319
+ localStorage.setItem(DEVTOOLS_POSITION_KEY, JSON.stringify(dragPosition));
320
+ } catch {
321
+ // Ignore storage errors
322
+ }
323
+ }, [isDragging, dragPosition, DEVTOOLS_POSITION_KEY]);
324
+
325
+ const handleResetPosition = useCallback(() => {
326
+ setDragPosition(DEFAULT_POSITION);
327
+ try {
328
+ localStorage.removeItem(DEVTOOLS_POSITION_KEY);
329
+ } catch {
330
+ // Ignore storage errors
331
+ }
332
+ }, [DEVTOOLS_POSITION_KEY]);
333
+
239
334
  // Inject/update component-specific preview styles whenever overrides change
240
335
  useEffect(() => {
241
336
  if (!mounted) return;
@@ -2172,8 +2267,9 @@ export function SonanceDevTools() {
2172
2267
  // Close and reset
2173
2268
  const handleClose = useCallback(() => {
2174
2269
  setIsOpen(false);
2175
- // Disable inspector so user can interact with the app
2270
+ // Disable inspector and vision mode so user can interact with the app
2176
2271
  setInspectorEnabled(false);
2272
+ setVisionModeActive(false);
2177
2273
  resetThemeFromDOM();
2178
2274
  // Reset color change tracking for next session
2179
2275
  setColorsExplicitlyChanged(false);
@@ -2201,19 +2297,37 @@ export function SonanceDevTools() {
2201
2297
 
2202
2298
  const panel = isOpen && (
2203
2299
  <div
2300
+ ref={panelRef}
2204
2301
  data-sonance-devtools="true"
2205
2302
  className={cn(
2206
2303
  "fixed bottom-6 right-6 z-[9999]",
2207
2304
  "w-[360px] max-h-[80vh]",
2208
2305
  "bg-white rounded-lg shadow-2xl border border-gray-200",
2209
2306
  "flex flex-col overflow-hidden",
2210
- "font-['Montserrat',sans-serif]"
2307
+ "font-['Montserrat',sans-serif]",
2308
+ isDragging && "select-none"
2211
2309
  )}
2212
- style={{ colorScheme: "light" }}
2310
+ style={{
2311
+ colorScheme: "light",
2312
+ transform: `translate(${dragPosition.x}px, ${dragPosition.y}px)`,
2313
+ }}
2213
2314
  >
2214
- {/* Header - Simplified */}
2215
- <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-[#333F48]">
2315
+ {/* Header - Draggable */}
2316
+ <div
2317
+ ref={headerRef}
2318
+ className={cn(
2319
+ "flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-[#333F48]",
2320
+ "cursor-move touch-none"
2321
+ )}
2322
+ onPointerDown={handleDragStart}
2323
+ onPointerMove={handleDragMove}
2324
+ onPointerUp={handleDragEnd}
2325
+ onPointerCancel={handleDragEnd}
2326
+ onDoubleClick={handleResetPosition}
2327
+ title="Drag to move • Double-click to reset position"
2328
+ >
2216
2329
  <div className="flex items-center gap-2">
2330
+ <GripHorizontal className="h-4 w-4 text-white/50" />
2217
2331
  <Palette className="h-5 w-5 text-[#00A3E1]" />
2218
2332
  <span id="span-sonance-devtools" className="text-sm font-semibold text-white">
2219
2333
  Sonance DevTools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.20",
3
+ "version": "1.3.21",
4
4
  "description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",