-
+
+ {/* AI Chat entry point section */}
+
-
+ >
);
}
diff --git a/app/api/generate-theme/route.ts b/app/api/generate-theme/route.ts
index 0c76736d..9ad0e4af 100644
--- a/app/api/generate-theme/route.ts
+++ b/app/api/generate-theme/route.ts
@@ -1,16 +1,22 @@
-import { createGroq } from "@ai-sdk/groq";
-import { createFallback } from "ai-fallback";
-import { generateObject } from "ai";
-import { z } from "zod";
-import { themeStylePropsSchema } from "@/types/theme";
-import { kv } from "@vercel/kv";
-import { Ratelimit } from "@upstash/ratelimit";
-import { NextRequest } from "next/server";
-import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { auth } from "@/lib/auth";
+import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
+import { themeStylePropsSchema } from "@/types/theme";
+import { createGoogleGenerativeAI } from "@ai-sdk/google";
+import { createGroq } from "@ai-sdk/groq";
+import { Ratelimit } from "@upstash/ratelimit";
+import { kv } from "@vercel/kv";
+import { generateObject } from "ai";
+import { createFallback } from "ai-fallback";
+import { NextRequest } from "next/server";
+import { z } from "zod";
const requestSchema = z.object({
- prompt: z.string().min(1),
+ prompt: z
+ .string()
+ .min(1)
+ .max(AI_PROMPT_CHARACTER_LIMIT + 4000, {
+ message: `Failed to generate theme. Input character limit exceeded.`,
+ }),
});
// Create a new schema based on themeStylePropsSchema excluding 'spacing'
@@ -19,9 +25,12 @@ const themeStylePropsWithoutSpacing = themeStylePropsSchema.omit({
});
// Define the main theme schema using the modified props schema
-const themeSchemaWithoutSpacing = z.object({
- light: themeStylePropsWithoutSpacing,
- dark: themeStylePropsWithoutSpacing,
+const responseSchema = z.object({
+ text: z.string().describe("A concise paragraph on the generated theme"),
+ theme: z.object({
+ light: themeStylePropsWithoutSpacing,
+ dark: themeStylePropsWithoutSpacing,
+ }),
});
// Create Rate limit - 5 requests per 60 seconds
@@ -65,10 +74,7 @@ export async function POST(req: NextRequest) {
});
const model = createFallback({
- models: [
- groq("llama-3.3-70b-versatile"),
- google("gemini-2.0-flash-lite"),
- ],
+ models: [groq("llama-3.3-70b-versatile"), google("gemini-2.0-flash-lite")],
onError: (error, modelId) => {
console.error(`Error with model ${modelId}:`, error);
},
@@ -77,12 +83,34 @@ export async function POST(req: NextRequest) {
const { object: theme } = await generateObject({
model,
- schema: themeSchemaWithoutSpacing,
- system: `You are an AI generating Shadcn UI color themes.
-Format: Use Hex values (#000000) ONLY for colors. Use shadow-opacity instead of using rgba for shadow-color.
-Requirement: Ensure light/dark mode cohesion. If asked to change the theme's main color (e.g., "make it green"), adjust related colors (--accent, --secondary, --ring, --border) along with --primary to create a cohesive new palette.
-Ensure sufficient contrast between foreground and background colors.`,
- prompt: `Generate Shadcn theme. Input: ${prompt}`,
+ schema: responseSchema,
+ system: `# Role
+ You are tweakcn, an expert shadcn/ui theme generator.
+
+ # Token Groups
+ - **Brand**: primary, secondary, accent, ring
+ - **Surfaces**: background, card, popover, muted, sidebar
+ - **Typography**: font-sans, font-serif, font-mono
+ - **Contrast pairs**: Some colors have a -foreground counterpart for text, (e.g., primary/primary-foreground, secondary/secondary-foreground)
+
+ # Rules **IMPORTANT**
+ - Output JSON matching schema exactly
+ - Colors: HEX only (#RRGGBB), do NOT output rgba()
+ - Shadows: Shadow Opacity is handled separately (e.g., via \`--shadow-opacity\`);
+ - Generate harmonious light/dark modes
+ - Ensure contrast for base/foreground pairs
+ - Don't change typography unless requested
+
+ # Color Change Logic
+ - "Make it [color]" → modify brand colors only
+ - "Background darker/lighter" → modify surface colors only
+ - Specific tokens requests → change those tokens + their direct foreground pairs
+ - "Change [colors] in light/dark mode" → change those colors only in the requested mode, leave the other mode unchanged. (e.g. "Make primary color in light mode a little darker" → only change primary in light mode, keep dark mode unchanged)
+ - Maintain color harmony across all related tokens
+
+ # Text Description
+ Fill the \`text\` field in a friendly way, for example: "I've generated..." or "Alright, I've whipped up..."`,
+ prompt: `Create shadcn/ui theme for: ${prompt}`,
});
return new Response(JSON.stringify(theme), {
@@ -90,6 +118,15 @@ Ensure sufficient contrast between foreground and background colors.`,
});
} catch (error) {
console.error(error);
+
+ // Handle Zod validation errors specifically
+ if (error instanceof z.ZodError) {
+ const firstError = error.errors[0];
+ return new Response(firstError.message, {
+ status: 400,
+ });
+ }
+
// Consider more specific error handling based on AI SDK errors if needed
return new Response("Error generating theme", { status: 500 });
}
diff --git a/app/editor/theme/[[...themeId]]/page.tsx b/app/editor/theme/[[...themeId]]/page.tsx
index 801a71ea..6b9ea2ef 100644
--- a/app/editor/theme/[[...themeId]]/page.tsx
+++ b/app/editor/theme/[[...themeId]]/page.tsx
@@ -1,12 +1,11 @@
-import { getEditorConfig } from "@/config/editors";
-import { cn } from "@/lib/utils";
-import Editor from "@/components/editor/editor";
-import { Metadata } from "next";
-import { Header } from "@/components/header";
import { getTheme } from "@/actions/themes";
-import { Suspense } from "react";
-import { Loading } from "@/components/loading";
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
+import Editor from "@/components/editor/editor";
+import { Header } from "@/components/header";
+import { Loading } from "@/components/loading";
+import { cn } from "@/lib/utils";
+import { Metadata } from "next";
+import { Suspense } from "react";
export const metadata: Metadata = {
title: "tweakcn — Theme Generator for shadcn/ui",
@@ -14,30 +13,22 @@ export const metadata: Metadata = {
"Easily customize and preview your shadcn/ui theme with tweakcn. Modify colors, fonts, and styles in real-time.",
};
-export default async function Component({
- params,
-}: {
- params: Promise<{ themeId: string[] }>;
-}) {
+export default async function Component({ params }: { params: Promise<{ themeId: string[] }> }) {
const { themeId } = await params;
- const themePromise =
- themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null);
+ const themePromise = themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null);
return (
<>
diff --git a/app/globals.css b/app/globals.css
index 18cf97ef..9cbc8591 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -117,7 +117,7 @@ body {
/* Mention styles */
.mention {
- @apply bg-primary/10 text-primary rounded-md px-1;
+ @apply bg-primary/10 rounded-md px-1 font-mono text-sm font-semibold;
}
@keyframes text {
@@ -133,7 +133,7 @@ body {
animation: text 3s linear infinite;
}
-.scrollbar-thin {
+@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
@@ -141,3 +141,11 @@ body {
width: 8px;
}
}
+
+@utility scrollbar-gutter-stable {
+ scrollbar-gutter: stable;
+}
+
+@utility scrollbar-gutter-both {
+ scrollbar-gutter: stable both-edges;
+}
diff --git a/components/copy-button.tsx b/components/copy-button.tsx
new file mode 100644
index 00000000..1039ffb1
--- /dev/null
+++ b/components/copy-button.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
+import { cn } from "@/lib/utils";
+import { Copy, CopyCheck } from "lucide-react";
+import { ComponentProps } from "react";
+import { TooltipWrapper } from "./tooltip-wrapper";
+import { Button } from "./ui/button";
+
+interface CopyButtonProps extends ComponentProps
{
+ textToCopy: string;
+ successMessage?: {
+ title?: string;
+ description?: string;
+ };
+}
+
+export function CopyButton({ textToCopy, successMessage, className, ...props }: CopyButtonProps) {
+ const { copyToClipboard, hasCopied } = useCopyToClipboard();
+
+ return (
+
+
+
+ );
+}
diff --git a/components/editor/action-bar/action-bar.tsx b/components/editor/action-bar/action-bar.tsx
index 46d8f8fe..aa4168fc 100644
--- a/components/editor/action-bar/action-bar.tsx
+++ b/components/editor/action-bar/action-bar.tsx
@@ -1,6 +1,7 @@
"use client";
import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons";
+import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { DialogActionsProvider, useDialogActions } from "@/hooks/use-dialog-actions";
export function ActionBar() {
@@ -12,27 +13,20 @@ export function ActionBar() {
}
function ActionBarContent() {
- const {
- isCreatingTheme,
- handleSaveClick,
- handleAiGenerateClick,
- handleShareClick,
- setCssImportOpen,
- setCodePanelOpen,
- } = useDialogActions();
+ const { isCreatingTheme, handleSaveClick, handleShareClick, setCssImportOpen, setCodePanelOpen } =
+ useDialogActions();
return (
-
+
setCssImportOpen(true)}
onCodeClick={() => setCodePanelOpen(true)}
onSaveClick={() => handleSaveClick()}
- onAiGenerateClick={handleAiGenerateClick}
isSaving={isCreatingTheme}
onShareClick={handleShareClick}
/>
-
+
);
}
diff --git a/components/editor/action-bar/components/action-bar-buttons.tsx b/components/editor/action-bar/components/action-bar-buttons.tsx
index 63324a81..73b43a19 100644
--- a/components/editor/action-bar/components/action-bar-buttons.tsx
+++ b/components/editor/action-bar/components/action-bar-buttons.tsx
@@ -1,22 +1,22 @@
import { Separator } from "@/components/ui/separator";
-import { ThemeToggle } from "./theme-toggle";
-import { ImportButton } from "./import-button";
-import { ResetButton } from "./reset-button";
-import { SaveButton } from "./save-button";
-import { CodeButton } from "./code-button";
+import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
+import { CodeButton } from "./code-button";
import { EditButton } from "./edit-button";
+import { ImportButton } from "./import-button";
import { MoreOptions } from "./more-options";
-import { AIGenerateButton } from "./ai-generate-button";
+import { ResetButton } from "./reset-button";
+import { SaveButton } from "./save-button";
import { ShareButton } from "./share-button";
+import { ThemeToggle } from "./theme-toggle";
import { UndoRedoButtons } from "./undo-redo-buttons";
+import { useAIChatStore } from "@/store/ai-chat-store";
interface ActionBarButtonsProps {
onImportClick: () => void;
onCodeClick: () => void;
onSaveClick: () => void;
- onAiGenerateClick: () => void;
onShareClick: (id?: string) => void;
isSaving: boolean;
}
@@ -25,34 +25,40 @@ export function ActionBarButtons({
onImportClick,
onCodeClick,
onSaveClick,
- onAiGenerateClick,
onShareClick,
isSaving,
}: ActionBarButtonsProps) {
const { themeState, resetToCurrentPreset, hasUnsavedChanges } = useEditorStore();
-
+ const { loading: aiGenerationLoading } = useAIThemeGeneration();
const { getPreset } = useThemePresetStore();
const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined;
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
+ const { clearMessages } = useAIChatStore();
+
+ const handleReset = () => {
+ resetToCurrentPreset();
+ clearMessages();
+ };
return (
-
+
-
+
+
-
-
+
-
- {isSavedPreset &&
}
-
onShareClick(themeState.preset)} />
-
-
+ {isSavedPreset && (
+
+ )}
+ onShareClick(themeState.preset)} disabled={aiGenerationLoading} />
+
+
);
}
diff --git a/components/editor/action-bar/components/ai-generate-dialog.tsx b/components/editor/action-bar/components/ai-generate-dialog.tsx
deleted file mode 100644
index ecb48db3..00000000
--- a/components/editor/action-bar/components/ai-generate-dialog.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-"use client";
-
-import { lazy, Suspense, useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Sparkles, Loader2 } from "lucide-react";
-import type { JSONContent } from "@tiptap/react";
-import { Loading } from "@/components/loading";
-
-const CustomTextarea = lazy(
- () => import("@/components/editor/custom-textarea")
-);
-
-interface AIGenerateDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- loading: boolean;
- onGenerate: (textPrompt: string, jsonPrompt: string) => void;
-}
-
-export function AIGenerateDialog({
- open,
- onOpenChange,
- loading,
- onGenerate,
-}: AIGenerateDialogProps) {
- const [prompt, setPrompt] = useState("");
- const [jsonPrompt, setJsonPrompt] = useState("");
-
- const handleContentChange = (
- textContent: string,
- jsonContent: JSONContent
- ) => {
- setJsonPrompt(JSON.stringify(jsonContent));
- setPrompt(textContent);
- };
-
- const handleGenerate = () => {
- onGenerate(prompt, jsonPrompt);
- };
-
- return (
-
- );
-}
diff --git a/components/editor/action-bar/components/code-button.tsx b/components/editor/action-bar/components/code-button.tsx
index 083e644f..6c4c17b9 100644
--- a/components/editor/action-bar/components/code-button.tsx
+++ b/components/editor/action-bar/components/code-button.tsx
@@ -1,26 +1,25 @@
-import { Braces } from "lucide-react";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { Braces } from "lucide-react";
-interface CodeButtonProps {
- onCodeClick: () => void;
-}
+interface CodeButtonProps extends React.ComponentProps {}
-export function CodeButton({ onCodeClick }: CodeButtonProps) {
+export function CodeButton({ className, ...props }: CodeButtonProps) {
return (
-
-
-
-
- View theme code
-
+
+
+
);
}
diff --git a/components/editor/action-bar/components/edit-button.tsx b/components/editor/action-bar/components/edit-button.tsx
index 9a79459a..c1c5e15d 100644
--- a/components/editor/action-bar/components/edit-button.tsx
+++ b/components/editor/action-bar/components/edit-button.tsx
@@ -1,32 +1,39 @@
-import { PenLine } from "lucide-react";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { PenLine } from "lucide-react";
import Link from "next/link";
+import { usePathname, useSearchParams } from "next/navigation";
-interface EditButtonProps {
+interface EditButtonProps extends React.ComponentProps {
themeId: string;
}
-export function EditButton({ themeId }: EditButtonProps) {
+export function EditButton({ themeId, disabled, className, ...props }: EditButtonProps) {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const isEditing = pathname.includes(themeId);
+
+ // Keep the current search params for tab persistence
+ const href = `/editor/theme/${themeId}?${searchParams}`;
+
return (
-
-
-
-
-
-
- Edit theme
-
+
+
+
+
+
);
}
diff --git a/components/editor/action-bar/components/import-button.tsx b/components/editor/action-bar/components/import-button.tsx
index 0d273937..cbf0a8a1 100644
--- a/components/editor/action-bar/components/import-button.tsx
+++ b/components/editor/action-bar/components/import-button.tsx
@@ -1,30 +1,25 @@
-import { FileCode } from "lucide-react";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { FileCode } from "lucide-react";
-interface ImportButtonProps {
- onImportClick: () => void;
-}
+interface ImportButtonProps extends React.ComponentProps {}
-export function ImportButton({ onImportClick }: ImportButtonProps) {
+export function ImportButton({ className, ...props }: ImportButtonProps) {
return (
-
-
-
-
- Import CSS variables
-
+
+
+
);
}
diff --git a/components/editor/action-bar/components/more-options.tsx b/components/editor/action-bar/components/more-options.tsx
index e0e0a5df..b2db5a31 100644
--- a/components/editor/action-bar/components/more-options.tsx
+++ b/components/editor/action-bar/components/more-options.tsx
@@ -1,25 +1,27 @@
-import { useState } from "react";
+import McpIcon from "@/assets/mcp.svg";
+import ContrastChecker from "@/components/editor/contrast-checker";
+import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Button } from "@/components/ui/button";
-import { MoreVertical } from "lucide-react";
-import { MCPDialog } from "./mcp-dialog";
-import McpIcon from "@/assets/mcp.svg";
-import ContrastChecker from "@/components/editor/contrast-checker";
import { useEditorStore } from "@/store/editor-store";
+import { MoreVertical } from "lucide-react";
+import { useState } from "react";
+import { MCPDialog } from "./mcp-dialog";
-export function MoreOptions() {
+interface MoreOptionsProps extends React.ComponentProps {}
+
+export function MoreOptions({ ...props }: MoreOptionsProps) {
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const { themeState } = useEditorStore();
return (
<>
-
+
@@ -30,9 +32,7 @@ export function MoreOptions() {
MCP
e.preventDefault()}>
-
+
diff --git a/components/editor/action-bar/components/reset-button.tsx b/components/editor/action-bar/components/reset-button.tsx
index a230d39e..d778e955 100644
--- a/components/editor/action-bar/components/reset-button.tsx
+++ b/components/editor/action-bar/components/reset-button.tsx
@@ -1,32 +1,25 @@
-import { RefreshCw } from "lucide-react";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { RefreshCw } from "lucide-react";
-interface ResetButtonProps {
- onReset: () => void;
- isDisabled: boolean;
-}
+interface ResetButtonProps extends React.ComponentProps {}
-export function ResetButton({ onReset, isDisabled }: ResetButtonProps) {
+export function ResetButton({ className, ...props }: ResetButtonProps) {
return (
-
-
-
-
- Reset to preset defaults
-
+
+
+
);
}
diff --git a/components/editor/action-bar/components/save-button.tsx b/components/editor/action-bar/components/save-button.tsx
index dfc50036..0873e728 100644
--- a/components/editor/action-bar/components/save-button.tsx
+++ b/components/editor/action-bar/components/save-button.tsx
@@ -1,32 +1,28 @@
-import { Heart, Loader2 } from "lucide-react";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { Heart, Loader2 } from "lucide-react";
-interface SaveButtonProps {
- onSaveClick: () => void;
+interface SaveButtonProps extends React.ComponentProps {
isSaving: boolean;
}
-export function SaveButton({ onSaveClick, isSaving }: SaveButtonProps) {
+export function SaveButton({ isSaving, disabled, className, ...props }: SaveButtonProps) {
return (
-
-
-
-
- Save theme
-
+
+
+
);
}
diff --git a/components/editor/action-bar/components/share-button.tsx b/components/editor/action-bar/components/share-button.tsx
index 9643a732..5e8bce38 100644
--- a/components/editor/action-bar/components/share-button.tsx
+++ b/components/editor/action-bar/components/share-button.tsx
@@ -1,36 +1,39 @@
-import { Loader2, Share2 } from "lucide-react";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { Loader2, Share2 } from "lucide-react";
-interface ShareButtonProps {
- onShareClick: () => void;
+interface ShareButtonProps extends React.ComponentProps {
isSharing?: boolean;
}
-export function ShareButton({ onShareClick, isSharing }: ShareButtonProps) {
+export function ShareButton({
+ onClick,
+ isSharing,
+ disabled,
+ className,
+ ...props
+}: ShareButtonProps) {
return (
-
-
-
-
- Share theme
-
+
+
+
);
}
diff --git a/components/editor/action-bar/components/undo-redo-buttons.tsx b/components/editor/action-bar/components/undo-redo-buttons.tsx
index d7ac7794..69d3e58f 100644
--- a/components/editor/action-bar/components/undo-redo-buttons.tsx
+++ b/components/editor/action-bar/components/undo-redo-buttons.tsx
@@ -1,35 +1,38 @@
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useEditorStore } from "@/store/editor-store";
import { Redo, Undo } from "lucide-react";
-export function UndoRedoButtons() {
+interface UndoRedoButtonsProps extends React.ComponentProps {}
+
+export function UndoRedoButtons({ disabled, ...props }: UndoRedoButtonsProps) {
const { undo, redo, canUndo, canRedo } = useEditorStore();
return (
-
-
-
-
-
-
-
- Undo
-
-
-
-
-
-
-
- Redo
-
-
-
-
+
+
+
+
+
+
+
+
+
);
}
diff --git a/components/editor/ai/ai-chat-form.tsx b/components/editor/ai/ai-chat-form.tsx
new file mode 100644
index 00000000..08175c82
--- /dev/null
+++ b/components/editor/ai/ai-chat-form.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { Loading } from "@/components/loading";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
+import { Button } from "@/components/ui/button";
+import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
+import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
+import { cn } from "@/lib/utils";
+import { useAIChatStore } from "@/store/ai-chat-store";
+import { AIPromptData } from "@/types/ai";
+import { ArrowUp, Loader, Plus, StopCircle } from "lucide-react";
+import dynamic from "next/dynamic";
+import { useState } from "react";
+
+const CustomTextarea = dynamic(() => import("@/components/editor/custom-textarea"), {
+ ssr: false,
+ loading: () => ,
+});
+
+export function AIChatForm({
+ handleThemeGeneration,
+}: {
+ handleThemeGeneration: (promptData: AIPromptData | null) => void;
+}) {
+ const [promptData, setPromptData] = useState(null);
+ const { loading: aiGenerateLoading, cancelThemeGeneration } = useAIThemeGeneration();
+
+ const { messages, clearMessages } = useAIChatStore();
+
+ const handleContentChange = (newPromptData: AIPromptData) => {
+ setPromptData(newPromptData);
+ };
+
+ const handleGenerate = async () => {
+ if (!promptData?.content) return;
+
+ handleThemeGeneration(promptData);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {/* TODO: Add image upload */}
+ {aiGenerateLoading ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/editor/ai/ai-interface.tsx b/components/editor/ai/ai-interface.tsx
new file mode 100644
index 00000000..b231f060
--- /dev/null
+++ b/components/editor/ai/ai-interface.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { toast } from "@/components/ui/use-toast";
+import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
+import { usePostLoginAction } from "@/hooks/use-post-login-action";
+import { buildPrompt } from "@/lib/ai-theme-generator";
+import { authClient } from "@/lib/auth-client";
+import { cn } from "@/lib/utils";
+import { useAIChatStore } from "@/store/ai-chat-store";
+import { useAuthStore } from "@/store/auth-store";
+import { AIPromptData } from "@/types/ai";
+import { attachLastGeneratedThemeMention, mentionsCount } from "@/utils/ai-prompt";
+import dynamic from "next/dynamic";
+import { AIChatForm } from "./ai-chat-form";
+import { ClosableSuggestedPillActions } from "./closeable-suggested-pill-actions";
+
+const ChatMessages = dynamic(() => import("./chat-messages").then((mod) => mod.ChatMessages), {
+ ssr: false,
+});
+
+const NoMessagesPlaceholder = dynamic(
+ () => import("./no-messages-placeholder").then((mod) => mod.NoMessagesPlaceholder),
+ {
+ ssr: false,
+ }
+);
+
+export function AIInterface() {
+ const { generateTheme } = useAIThemeGeneration();
+ const { messages, addUserMessage, addAssistantMessage, resetMessagesUpToIndex } =
+ useAIChatStore();
+ const { data: session } = authClient.useSession();
+ const { openAuthDialog } = useAuthStore();
+
+ const hasMessages = messages.length > 0;
+
+ const handleThemeGeneration = async (promptData: AIPromptData | null) => {
+ if (!session) {
+ openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", { promptData });
+ return;
+ }
+
+ if (!promptData) {
+ toast({
+ title: "Error",
+ description: "Failed to generate theme. Please try again.",
+ });
+ return;
+ }
+
+ let transformedPromptData = promptData;
+
+ if (mentionsCount(promptData) === 0) {
+ transformedPromptData = attachLastGeneratedThemeMention(promptData);
+ }
+
+ addUserMessage({
+ promptData: transformedPromptData,
+ });
+
+ const result = await generateTheme(buildPrompt(transformedPromptData));
+
+ if (!result) {
+ addAssistantMessage({
+ content: "Failed to generate theme.",
+ });
+ return;
+ }
+
+ addAssistantMessage({
+ content:
+ result?.text ??
+ (result?.theme ? "Here's the theme I generated for you." : "Failed to generate theme."),
+ themeStyles: result?.theme,
+ });
+ };
+
+ const handleRetry = async (messageIndex: number) => {
+ const messageToRetry = messages[messageIndex];
+
+ if (!messageToRetry || messageToRetry.role !== "user" || !messageToRetry.promptData) {
+ toast({
+ title: "Error",
+ description: "Cannot retry this message.",
+ });
+ return;
+ }
+
+ // Reset messages up to the retry point (remove the user message and any subsequent messages)
+ resetMessagesUpToIndex(messageIndex);
+
+ // Resend the prompt
+ await handleThemeGeneration(messageToRetry.promptData);
+ };
+
+ usePostLoginAction("AI_GENERATE_FROM_CHAT", ({ promptData }) => {
+ handleThemeGeneration(promptData);
+ });
+
+ return (
+
+
+ {hasMessages ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Chat form input and suggestions */}
+
+
+ );
+}
diff --git a/components/editor/ai/ai-pill-action-button.tsx b/components/editor/ai/ai-pill-action-button.tsx
new file mode 100644
index 00000000..94c5d04f
--- /dev/null
+++ b/components/editor/ai/ai-pill-action-button.tsx
@@ -0,0 +1,31 @@
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { ComponentProps } from "react";
+
+export interface AIPillActionButtonProps extends ComponentProps {}
+
+export function AIPillActionButton({
+ className,
+ children,
+ disabled,
+ ...props
+}: AIPillActionButtonProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/components/editor/ai/chat-messages.tsx b/components/editor/ai/chat-messages.tsx
new file mode 100644
index 00000000..c2f5f085
--- /dev/null
+++ b/components/editor/ai/chat-messages.tsx
@@ -0,0 +1,224 @@
+import Logo from "@/assets/logo.svg";
+import { CopyButton } from "@/components/copy-button";
+import { TooltipWrapper } from "@/components/tooltip-wrapper";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useAIChat } from "@/hooks/use-ai-chat";
+import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
+import { cn } from "@/lib/utils";
+import { useEditorStore } from "@/store/editor-store";
+import { type ChatMessage as ChatMessageType } from "@/types/ai";
+import { ThemeStyles } from "@/types/theme";
+import { buildAIPromptRender } from "@/utils/ai-prompt";
+import { Goal, RefreshCw } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import ColorPreview from "../theme-preview/color-preview";
+import { ChatThemePreview } from "./chat-theme-preview";
+import { LoadingLogo } from "./loading-logo";
+
+type ChatMessagesProps = {
+ onRetry?: (messageIndex: number) => void;
+};
+
+export function ChatMessages({ onRetry }: ChatMessagesProps) {
+ const [isScrollTop, setIsScrollTop] = useState(true);
+ const { messages } = useAIChat();
+ const { loading: isAIGenerating } = useAIThemeGeneration();
+ const previousMessages = useRef(messages);
+
+ const messagesStartRef = useRef(null);
+ const messagesEndRef = useRef(null);
+
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ // When switching tabs, messages do not change, so we don't need to animate the scroll
+ const didMessagesChange = previousMessages.current.length !== messages.length;
+ messagesEndRef.current.scrollIntoView({ behavior: didMessagesChange ? "smooth" : "auto" });
+
+ // Update the previous messages ref
+ previousMessages.current = messages;
+ }
+ }, [messages, isAIGenerating]);
+
+ // Toggle top fade out effect when scrolling
+ useEffect(() => {
+ const startMarker = messagesStartRef.current;
+
+ if (!startMarker) return;
+
+ const observerOptions = {
+ root: null, // Use viewport as root for more reliable detection
+ threshold: 0,
+ };
+
+ const startMessagesObserver = new IntersectionObserver(([entry]) => {
+ setIsScrollTop(entry.isIntersecting);
+ }, observerOptions);
+
+ startMessagesObserver.observe(startMarker);
+
+ return () => startMessagesObserver.disconnect();
+ }, []);
+
+ return (
+
+ {/* Top fade out effect when scrolling */}
+
+
+
+
+ {messages.map((message, index) => (
+
+ ))}
+
+ {/* Loading message when AI is generating */}
+ {isAIGenerating && (
+
+
+
+
+
+
+ Generating
+ .
+ .
+ .
+
+
+ )}
+
+
+
+
+ );
+}
+
+type ChatMessageProps = {
+ message: ChatMessageType;
+ messageIndex?: number;
+ onRetry?: (messageIndex: number) => void;
+};
+
+export default function ChatMessage({ message, messageIndex, onRetry }: ChatMessageProps) {
+ const isUser = message.role === "user";
+ const isAssistant = message.role === "assistant";
+
+ const { themeState, setThemeState } = useEditorStore();
+ const { loading: isAIGenerating } = useAIThemeGeneration();
+
+ const handleResetThemeToMessageCheckpoint = (themeStyles?: ThemeStyles) => {
+ if (!themeStyles) return;
+
+ setThemeState({
+ ...themeState,
+ styles: themeStyles,
+ });
+ };
+
+ const handleRetry = () => {
+ if (messageIndex !== undefined && onRetry) {
+ onRetry(messageIndex);
+ }
+ };
+
+ const getDisplayContent = () => {
+ if (isUser && message.promptData) {
+ return buildAIPromptRender(message.promptData);
+ }
+ return message.content || "";
+ };
+
+ const getCopyContent = () => {
+ if (isUser && message.promptData) {
+ return message.promptData.content;
+ }
+ return message.content || "";
+ };
+
+ return (
+
+
+ {isAssistant && (
+
+
+
+ )}
+
+
+
+ {getDisplayContent()}
+
+
+ {isAssistant && message.themeStyles && (
+
+ )}
+
+
+ {isUser && onRetry && messageIndex !== undefined && (
+
+
+
+ )}
+
+
+
+ {isAssistant && message.themeStyles && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/editor/ai/chat-theme-preview.tsx b/components/editor/ai/chat-theme-preview.tsx
new file mode 100644
index 00000000..ae0a448d
--- /dev/null
+++ b/components/editor/ai/chat-theme-preview.tsx
@@ -0,0 +1,86 @@
+import { useTheme } from "@/components/theme-provider";
+import { cn } from "@/lib/utils";
+import { ThemeStyles } from "@/types/theme";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import { ComponentProps, useState } from "react";
+
+interface ChatThemePreviewProps extends ComponentProps<"div"> {
+ themeStyles: ThemeStyles;
+}
+
+export function ChatThemePreview({
+ themeStyles,
+ className,
+ children,
+ ...props
+}: ChatThemePreviewProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const { theme: mode } = useTheme();
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+ Preview
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/editor/ai/closeable-suggested-pill-actions.tsx b/components/editor/ai/closeable-suggested-pill-actions.tsx
new file mode 100644
index 00000000..537c0333
--- /dev/null
+++ b/components/editor/ai/closeable-suggested-pill-actions.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
+import { Button } from "@/components/ui/button";
+import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
+import { PROMPTS } from "@/utils/prompts";
+import { createCurrentThemePrompt } from "@/utils/ai-prompt";
+import { AIPromptData } from "@/types/ai";
+import { Sparkles, X } from "lucide-react";
+import { useState } from "react";
+import { AIPillActionButton } from "./ai-pill-action-button";
+
+export function ClosableSuggestedPillActions({
+ handleThemeGeneration,
+}: {
+ handleThemeGeneration: (promptData: AIPromptData | null) => void;
+}) {
+ const [hasClosedSuggestions, setHasClosedSuggestions] = useState(false);
+ const { loading: aiIsGenerating } = useAIThemeGeneration();
+
+ const handleSetPrompt = async (prompt: string) => {
+ const promptData = createCurrentThemePrompt({ prompt });
+ handleThemeGeneration(promptData);
+ };
+
+ if (hasClosedSuggestions) return null;
+
+ return (
+
+ {/* Fade out effect when scrolling */}
+
+
+
+
Suggestions
+
+
+
+
+ {Object.entries(PROMPTS).map(([key, { label, prompt }]) => (
+ handleSetPrompt(prompt)}
+ disabled={aiIsGenerating}
+ >
+ {label}
+
+ ))}
+
+
+ );
+}
diff --git a/app/ai/components/loading-logo.tsx b/components/editor/ai/loading-logo.tsx
similarity index 89%
rename from app/ai/components/loading-logo.tsx
rename to components/editor/ai/loading-logo.tsx
index 0048c430..349909e7 100644
--- a/app/ai/components/loading-logo.tsx
+++ b/components/editor/ai/loading-logo.tsx
@@ -6,11 +6,11 @@ export function LoadingLogo() {
<>
-
+
-
+
diff --git a/components/editor/ai/no-messages-placeholder.tsx b/components/editor/ai/no-messages-placeholder.tsx
new file mode 100644
index 00000000..59d6cdd1
--- /dev/null
+++ b/components/editor/ai/no-messages-placeholder.tsx
@@ -0,0 +1,210 @@
+import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
+import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
+import { authClient } from "@/lib/auth-client";
+import { cn } from "@/lib/utils";
+import { useThemePresetStore } from "@/store/theme-preset-store";
+import { AIPromptData } from "@/types/ai";
+import { createCurrentThemePrompt } from "@/utils/ai-prompt";
+import { PROMPTS } from "@/utils/prompts";
+import { Blend, PaintRoller, WandSparkles } from "lucide-react";
+import { ComponentProps, Fragment } from "react";
+import TabsTriggerPill from "../theme-preview/tabs-trigger-pill";
+
+interface RemixPrompt {
+ displayContent: string;
+ prompt: string;
+ basePreset: string;
+}
+
+interface Prompt {
+ displayContent: string;
+ prompt: string;
+}
+
+const CREATE_PROMPTS: Prompt[] = [
+ {
+ displayContent: "JavaScript/TypeScript Advent of Code playground",
+ prompt:
+ "Create a retro JavaScript Advent of Code theme. Use a grayish background with JavaScript yellow and TypeScript blue as primary/secondary colors. Change all fonts to monospace. Make borders sharp.",
+ },
+ {
+ displayContent: "Retro Terminal UI, green phosphor glow",
+ prompt:
+ "Create a retro terminal theme with black background (in dark mode), phosphorescent green primary and text, and all fonts monospace.",
+ },
+ {
+ displayContent: "Monochrome Manga-inspired theme",
+ prompt:
+ "Create a Manga-inspired theme. Monochromatic palette (black, off-white, grays), square corners, small contrast solid offset shadows, and high-contrast borders (black on light, off-white on dark). Use a playful font, like Architects daughter.",
+ },
+ {
+ displayContent: "I want a minimal Ghibli Studio vibe",
+ prompt:
+ "Generate a theme inspired by Studio Ghibli — soft pastels, natural greens, organic colors, and hand-drawn charm.",
+ },
+];
+
+const REMIX_PROMPTS: RemixPrompt[] = [
+ {
+ displayContent: "Make @Twitter but in a slick purple",
+ prompt: "Make @Twitter but in a slick purple",
+ basePreset: "twitter",
+ },
+ {
+ displayContent: "What if @Supabase was vibrant blue?",
+ prompt: "Make @Supabase but in vibrant blue",
+ basePreset: "supabase",
+ },
+ {
+ displayContent: "I want @Doom 64 with muted colors",
+ prompt: "I want @Doom 64 with alternate colors",
+ basePreset: "doom-64",
+ },
+];
+
+const VARIANT_PROMPTS: Prompt[] = [
+ {
+ displayContent: "Make my @Current Theme minimalistic",
+ prompt: PROMPTS.minimalStyle.prompt,
+ },
+ {
+ displayContent: "Flatten the colors of my @Current Theme",
+ prompt: PROMPTS.flatDesign.prompt,
+ },
+ {
+ displayContent: "Create a brutalist variant of my @Current Theme",
+ prompt: PROMPTS.brutalist.prompt,
+ },
+];
+
+// This can be moved to the ai-prompt utils
+const createPromptDataFromPreset = (prompt: string, presetName: string): AIPromptData => {
+ const preset = useThemePresetStore.getState().getPreset(presetName);
+
+ if (!preset) {
+ throw new Error(`Preset "${presetName}" not found`);
+ }
+
+ return {
+ content: prompt,
+ mentions: [
+ {
+ id: presetName,
+ label: preset.label ?? presetName,
+ themeData: {
+ light: preset.styles.light || {},
+ dark: preset.styles.dark || {},
+ },
+ },
+ ],
+ };
+};
+
+export function NoMessagesPlaceholder({
+ handleThemeGeneration,
+}: {
+ handleThemeGeneration: (promptData: AIPromptData | null) => void;
+}) {
+ const { data: session } = authClient.useSession();
+ const { loading: isGenerating } = useAIThemeGeneration();
+
+ const userName = session?.user.name?.split(" ")[0];
+ const heading = `What can I help you theme${userName ? `, ${userName}` : ""}?`;
+
+ return (
+
+
+ {heading}
+
+
+
+
+
+
+
+ Create
+
+
+
+ Remix
+
+
+
+ Tweak
+
+
+
+
+
+ {CREATE_PROMPTS.map((prompt, index) => (
+
+
+ handleThemeGeneration({
+ content: prompt.prompt,
+ mentions: [],
+ })
+ }
+ >
+ {prompt.displayContent}
+
+ {index < CREATE_PROMPTS.length - 1 && }
+
+ ))}
+
+
+
+ {REMIX_PROMPTS.map((prompt, index) => (
+
+
+ handleThemeGeneration(
+ createPromptDataFromPreset(prompt.prompt, prompt.basePreset)
+ )
+ }
+ >
+ {prompt.displayContent}
+
+ {index < REMIX_PROMPTS.length - 1 && }
+
+ ))}
+
+
+
+ {VARIANT_PROMPTS.map((prompt, index) => (
+
+
+ handleThemeGeneration(createCurrentThemePrompt({ prompt: prompt.prompt }))
+ }
+ >
+ {prompt.displayContent}
+
+ {index < VARIANT_PROMPTS.length - 1 && }
+
+ ))}
+
+
+
+ );
+}
+
+interface PromptButtonProps extends ComponentProps
{}
+
+function PromptButton({ className, children, ...props }: PromptButtonProps) {
+ return (
+
+ );
+}
diff --git a/components/editor/contrast-checker.tsx b/components/editor/contrast-checker.tsx
index 1cb512b9..c3c8758a 100644
--- a/components/editor/contrast-checker.tsx
+++ b/components/editor/contrast-checker.tsx
@@ -189,13 +189,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
return (