diff --git a/apps/docs/app/[lang]/website-designer/page.tsx b/apps/docs/app/[lang]/website-designer/page.tsx new file mode 100644 index 00000000..a727b058 --- /dev/null +++ b/apps/docs/app/[lang]/website-designer/page.tsx @@ -0,0 +1,10 @@ +import { WebsiteDesigner } from "@repo/elements/website-designer"; + +export default function WebsiteDesignerPage() { + return ( +
+

Website Designer

+ +
+ ); +} diff --git a/apps/docs/app/api/chat/tools.ts b/apps/docs/app/api/chat/tools.ts index 7c8abca6..9a7bf9d9 100644 --- a/apps/docs/app/api/chat/tools.ts +++ b/apps/docs/app/api/chat/tools.ts @@ -1,6 +1,14 @@ import type { ToolSet, UIMessageStreamWriter } from "ai"; import { tool } from "ai"; +import { + generateCodeExport, + generateDesignSpec, + GenerateCodeExportInputSchema, + GenerateDesignSpecInputSchema, + iterateDesignSpec, + IterateDesignSpecInputSchema, +} from "@repo/elements/website-designer"; import { initAdvancedSearch } from "fumadocs-core/search/server"; import z from "zod"; @@ -203,9 +211,38 @@ const list_docs = tool({ }, }); +const generate_design_spec = tool({ + description: + "Generate a full website design specification from a natural language prompt.", + execute: async ({ prompt }) => generateDesignSpec({ prompt }), + inputSchema: GenerateDesignSpecInputSchema, +}); + +const iterate_design_spec = tool({ + description: + "Apply a targeted update to an existing website design specification.", + execute: async ({ currentSpec, prompt, targetSectionId }) => + iterateDesignSpec({ + currentSpec, + prompt, + targetSectionId, + }), + inputSchema: IterateDesignSpecInputSchema, +}); + +const generate_code_export = tool({ + description: + "Export a React/Tailwind style code snippet for the provided website design spec.", + execute: async ({ spec }) => generateCodeExport({ spec }), + inputSchema: GenerateCodeExportInputSchema, +}); + export const createTools = (writer: UIMessageStreamWriter) => ({ + generate_code_export, + generate_design_spec, get_doc_page, + iterate_design_spec, list_docs, search_docs: search_docs(writer), }) satisfies ToolSet; diff --git a/packages/elements/__tests__/designer-tools.test.ts b/packages/elements/__tests__/designer-tools.test.ts new file mode 100644 index 00000000..f86838d9 --- /dev/null +++ b/packages/elements/__tests__/designer-tools.test.ts @@ -0,0 +1,45 @@ +import { + generateCodeExport, + generateDesignSpec, + iterateDesignSpec, +} from "../src/mcp/designer-tools"; +import { DesignSpecSchema } from "../src/types/designer"; + +describe("designer tools", () => { + it("generates a valid design spec", async () => { + const spec = await generateDesignSpec({ + prompt: "Make a dark-mode crypto landing page", + }); + + expect(() => DesignSpecSchema.parse(spec)).not.toThrow(); + expect(spec.theme.colors.background).toBe("#0F172A"); + expect( + spec.sections.some((section) => section.type === "hero") + ).toBeTruthy(); + }); + + it("iterates on the current design spec", async () => { + const currentSpec = await generateDesignSpec({ + prompt: "Create a startup website", + }); + + const result = await iterateDesignSpec({ + currentSpec, + prompt: "Make the primary color blue", + }); + + expect(result.spec.theme.colors.primary).toBe("#2563EB"); + expect(result.summary).toContain("Make the primary color blue"); + }); + + it("exports code for the current design", async () => { + const currentSpec = await generateDesignSpec({ + prompt: "Create a playful website", + }); + + const code = await generateCodeExport({ spec: currentSpec }); + + expect(code).toContain("export default function Website"); + expect(code).toContain(currentSpec.theme.colors.background); + }); +}); diff --git a/packages/elements/__tests__/website-designer.test.tsx b/packages/elements/__tests__/website-designer.test.tsx new file mode 100644 index 00000000..d739d542 --- /dev/null +++ b/packages/elements/__tests__/website-designer.test.tsx @@ -0,0 +1,94 @@ +// oxlint-disable eslint-plugin-unicorn(consistent-function-scoping) +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import type { ReactNode } from "react"; + +import { WebsiteDesigner } from "../src/components/WebsiteDesigner/website-designer"; + +const { StickToBottomMock, StickToBottomContent } = vi.hoisted(() => { + interface MockProps { + children?: ReactNode; + [key: string]: unknown; + } + + const StickyMock = ({ children, ...props }: MockProps) => ( +
+ {children} +
+ ); + + const StickyContent = ({ children, ...props }: MockProps) => ( +
{children}
+ ); + + return { + StickToBottomContent: StickyContent, + StickToBottomMock: StickyMock, + }; +}); + +// oxlint-disable-next-line typescript-eslint(consistent-type-imports) +vi.mock( + import("use-stick-to-bottom"), + () => { + const MockComponent = StickToBottomMock as typeof StickToBottomMock & { + Content: typeof StickToBottomContent; + }; + MockComponent.Content = StickToBottomContent; + + return { + StickToBottom: MockComponent, + useStickToBottomContext: () => ({ + isAtBottom: true, + scrollToBottom: vi.fn(), + }), + }; + } +); + +describe("websiteDesigner", () => { + it("creates an initial spec from chat prompt", async () => { + const user = userEvent.setup(); + + render(); + + await user.type( + screen.getByPlaceholderText("Describe the website you want..."), + "Make a dark-mode crypto landing page" + ); + + await user.click(screen.getByRole("button", { name: /submit/i })); + + await waitFor(() => { + expect(screen.getByText("Crypto Landing Page")).toBeInTheDocument(); + }); + }); + + it("supports manual property overrides and code export", async () => { + const user = userEvent.setup(); + + render(); + + await user.type( + screen.getByPlaceholderText("Describe the website you want..."), + "Create a startup website" + ); + await user.click(screen.getByRole("button", { name: /submit/i })); + + await waitFor(() => { + expect(screen.getByText("Modern Product Website")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "Properties" })); + const primaryColorInput = screen.getByLabelText("Primary"); + fireEvent.change(primaryColorInput, { target: { value: "#0000ff" } }); + + expect(primaryColorInput).toHaveValue("#0000ff"); + + await user.click(screen.getByRole("button", { name: "Export Code" })); + + await waitFor(() => { + expect(screen.getByTestId("exported-code")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/elements/package.json b/packages/elements/package.json index 0ac0705f..2f03816f 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -34,7 +34,8 @@ "shiki": "3.22.0", "streamdown": "^2.4.0", "tokenlens": "^1.3.1", - "use-stick-to-bottom": "^1.1.3" + "use-stick-to-bottom": "^1.1.3", + "zod": "^4.3.6" }, "devDependencies": { "@ai-sdk/react": "^3.0.41", @@ -50,7 +51,6 @@ "playwright": "^1.58.1", "typescript": "^5.9.3", "vitest": "^4.0.17", - "vitest-fail-on-console": "^0.10.1", - "zod": "^4.3.6" + "vitest-fail-on-console": "^0.10.1" } } diff --git a/packages/elements/src/components/WebsiteDesigner/preview-renderer.tsx b/packages/elements/src/components/WebsiteDesigner/preview-renderer.tsx new file mode 100644 index 00000000..7065a037 --- /dev/null +++ b/packages/elements/src/components/WebsiteDesigner/preview-renderer.tsx @@ -0,0 +1,159 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { CSSProperties } from "react"; + +import type { DesignSection, DesignSpec } from "../../types/designer"; + +interface PreviewRendererProps { + spec: DesignSpec | null; + loading?: boolean; +} + +const HeroBlock = ({ + section, +}: { + section: Extract; +}) => ( +
+

{section.title}

+

{section.subtitle}

+ +
+); + +const FeaturesBlock = ({ + section, +}: { + section: Extract; +}) => ( +
+

{section.title}

+
    + {section.items.map((item) => ( +
  • {item}
  • + ))} +
+
+); + +const PricingBlock = ({ + section, +}: { + section: Extract; +}) => ( +
+

{section.title}

+
+ {section.tiers.map((tier) => ( +
+

{tier.name}

+

{tier.price}

+

{tier.description}

+
+ ))} +
+
+); + +const FooterBlock = ({ + section, +}: { + section: Extract; +}) => ( +
+ {section.text} +
+); + +const SectionRenderer = ({ section }: { section: DesignSection }) => { + switch (section.type) { + case "hero": { + return ; + } + case "features": { + return ; + } + case "pricing": { + return ; + } + case "footer": { + return ; + } + default: { + return null; + } + } +}; + +const PreviewSkeleton = () => ( +
+
+
+
+
+); + +export const PreviewRenderer = ({ spec, loading }: PreviewRendererProps) => { + if (loading) { + return ; + } + + if (!spec) { + return ( +
+ Start by describing the website you want to design. +
+ ); + } + + return ( +
+ {spec.sections.map((section) => ( + + ))} +
+ ); +}; diff --git a/packages/elements/src/components/WebsiteDesigner/website-designer.tsx b/packages/elements/src/components/WebsiteDesigner/website-designer.tsx new file mode 100644 index 00000000..efade448 --- /dev/null +++ b/packages/elements/src/components/WebsiteDesigner/website-designer.tsx @@ -0,0 +1,398 @@ +// oxlint-disable eslint-plugin-react-perf(jsx-no-new-function-as-prop), eslint(no-negated-condition) +"use client"; + +import { Button } from "@repo/shadcn-ui/components/ui/button"; +import { Input } from "@repo/shadcn-ui/components/ui/input"; +import { Label } from "@repo/shadcn-ui/components/ui/label"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { ChatStatus } from "ai"; +import { useMemo, useState } from "react"; + +import { Conversation, ConversationContent } from "../../conversation"; +import { + generateCodeExport, + generateDesignSpec, + iterateDesignSpec, +} from "../../mcp/designer-tools"; +import { Message, MessageContent, MessageResponse } from "../../message"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, +} from "../../prompt-input"; +import type { DesignSpec } from "../../types/designer"; +import { PreviewRenderer } from "./preview-renderer"; + +interface DesignerMessage { + id: string; + role: "assistant" | "user"; + text: string; +} + +const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`; + +interface WebsiteDesignerProps { + className?: string; +} + +const updateTheme = ( + currentSpec: DesignSpec, + field: keyof DesignSpec["theme"], + value: string +): DesignSpec => { + if (field === "spacing" || field === "borderRadius") { + return { + ...currentSpec, + theme: { + ...currentSpec.theme, + [field]: Number.parseInt(value, 10) || 0, + }, + }; + } + + return { + ...currentSpec, + theme: { + ...currentSpec.theme, + [field]: value, + }, + }; +}; + +export const WebsiteDesigner = ({ className }: WebsiteDesignerProps) => { + const [activeTab, setActiveTab] = useState<"chat" | "properties">("chat"); + const [history, setHistory] = useState([]); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([]); + const [spec, setSpec] = useState(null); + const [status, setStatus] = useState("ready"); + const [exportedCode, setExportedCode] = useState(null); + + const loading = status === "submitted" || status === "streaming"; + + const handleSubmit = async (text: string) => { + if (!text.trim() || loading) { + return; + } + + setStatus("submitted"); + setMessages((current) => [ + ...current, + { id: makeId(), role: "user", text }, + ]); + + try { + if (!spec) { + const generatedSpec = await generateDesignSpec({ prompt: text }); + setSpec(generatedSpec); + setMessages((current) => [ + ...current, + { + id: makeId(), + role: "assistant", + text: "Created an initial design specification.", + }, + ]); + } else { + const { spec: nextSpec, summary } = await iterateDesignSpec({ + currentSpec: spec, + prompt: text, + }); + + setHistory((current) => [...current, spec]); + setSpec(nextSpec); + setMessages((current) => [ + ...current, + { id: makeId(), role: "assistant", text: summary }, + ]); + } + } catch (error) { + setMessages((current) => [ + ...current, + { + id: makeId(), + role: "assistant", + text: + error instanceof Error ? error.message : "Failed to update design.", + }, + ]); + setStatus("error"); + return; + } + + setInput(""); + setStatus("ready"); + }; + + const exportDisabled = !spec || loading; + + const canUndo = history.length > 0; + + const propertiesPanel = useMemo(() => { + if (!spec) { + return ( +

+ Generate a design first. +

+ ); + } + + return ( +
+
+ + + setSpec((current) => + current + ? { + ...current, + theme: { + ...current.theme, + colors: { + ...current.theme.colors, + primary: event.target.value, + }, + }, + } + : current + ) + } + type="color" + value={spec.theme.colors.primary} + /> +
+
+ + + setSpec((current) => + current + ? { + ...current, + theme: { + ...current.theme, + colors: { + ...current.theme.colors, + secondary: event.target.value, + }, + }, + } + : current + ) + } + type="color" + value={spec.theme.colors.secondary} + /> +
+
+ + + setSpec((current) => + current + ? { + ...current, + theme: { + ...current.theme, + colors: { + ...current.theme.colors, + background: event.target.value, + }, + }, + } + : current + ) + } + type="color" + value={spec.theme.colors.background} + /> +
+
+ + + setSpec((current) => + current + ? { + ...current, + theme: { + ...current.theme, + colors: { + ...current.theme.colors, + text: event.target.value, + }, + }, + } + : current + ) + } + type="color" + value={spec.theme.colors.text} + /> +
+
+ + +
+
+ + + setSpec((current) => + current + ? updateTheme(current, "spacing", event.target.value) + : current + ) + } + type="number" + value={spec.theme.spacing} + /> +
+
+ + + setSpec((current) => + current + ? updateTheme(current, "borderRadius", event.target.value) + : current + ) + } + type="number" + value={spec.theme.borderRadius} + /> +
+
+ ); + }, [spec]); + + return ( +
+ + +
+ + {exportedCode && ( +
+            {exportedCode}
+          
+ )} +
+
+ ); +}; diff --git a/packages/elements/src/mcp/designer-tools.ts b/packages/elements/src/mcp/designer-tools.ts new file mode 100644 index 00000000..b8e468b8 --- /dev/null +++ b/packages/elements/src/mcp/designer-tools.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; + +import type { DesignSpec } from "../types/designer"; +import { DesignSpecSchema } from "../types/designer"; + +const COLOR_BY_NAME: Record = { + black: "#111827", + blue: "#2563EB", + cyan: "#0891B2", + green: "#16A34A", + orange: "#EA580C", + pink: "#DB2777", + purple: "#7C3AED", + red: "#DC2626", + white: "#FFFFFF", + yellow: "#CA8A04", +}; + +const getColorFromPrompt = (prompt: string): string | undefined => { + const lowered = prompt.toLowerCase(); + for (const [name, hex] of Object.entries(COLOR_BY_NAME)) { + if (lowered.includes(name)) { + return hex; + } + } + + return undefined; +}; + +const createBaseSpec = (prompt: string): DesignSpec => { + const loweredPrompt = prompt.toLowerCase(); + const isDark = loweredPrompt.includes("dark"); + const isCrypto = loweredPrompt.includes("crypto"); + + const primaryColor = + getColorFromPrompt(prompt) ?? (isCrypto ? "#22C55E" : "#6366F1"); + + return { + intent: { + styleKeywords: loweredPrompt.includes("playful") + ? ["playful", "colorful"] + : [isDark ? "dark" : "light", isCrypto ? "crypto" : "modern"], + targetAudience: isCrypto ? "crypto traders" : "startup teams", + }, + layout: [ + { + id: "home", + name: "Home", + path: "/", + sectionIds: ["hero-1", "features-1", "pricing-1", "footer-1"], + }, + ], + sections: [ + { + ctaText: isCrypto ? "Start Trading" : "Get Started", + id: "hero-1", + subtitle: isCrypto + ? "Secure, lightning-fast access to digital assets." + : "Launch your next product with confidence.", + title: isCrypto ? "Crypto Landing Page" : "Modern Product Website", + type: "hero", + }, + { + id: "features-1", + items: isCrypto + ? ["Real-time analytics", "Cold-wallet security", "Instant swaps"] + : ["Fast setup", "Reusable components", "Actionable insights"], + title: "Features", + type: "features", + }, + { + id: "pricing-1", + tiers: [ + { + description: "Great for trying things out", + name: "Starter", + price: "$0", + }, + { + description: "For scaling teams", + name: "Pro", + price: "$29/mo", + }, + ], + title: "Pricing", + type: "pricing", + }, + { + id: "footer-1", + text: "© 2026 Your Company. All rights reserved.", + type: "footer", + }, + ], + theme: { + borderRadius: loweredPrompt.includes("rounded") ? 20 : 12, + colors: { + background: isDark ? "#0F172A" : "#F8FAFC", + primary: primaryColor, + secondary: isDark ? "#334155" : "#E2E8F0", + text: isDark ? "#F8FAFC" : "#0F172A", + }, + fontFamily: loweredPrompt.includes("serif") + ? "Georgia, serif" + : "Inter, sans-serif", + spacing: 24, + }, + }; +}; + +const applySectionMutation = ( + section: DesignSpec["sections"][number], + prompt: string +): DesignSpec["sections"][number] => { + const loweredPrompt = prompt.toLowerCase(); + + if (section.type === "hero") { + if (loweredPrompt.includes("playful")) { + return { + ...section, + ctaText: "Let's Go!", + subtitle: "Bold ideas, bright colors, and joyful interactions.", + }; + } + + if (loweredPrompt.includes("professional")) { + return { + ...section, + ctaText: "Book a Demo", + subtitle: "Enterprise-grade reliability for serious growth.", + }; + } + } + + return section; +}; + +export const GenerateDesignSpecInputSchema = z.object({ + prompt: z.string().min(1), +}); + +export const IterateDesignSpecInputSchema = z.object({ + currentSpec: DesignSpecSchema, + prompt: z.string().min(1), + targetSectionId: z.string().min(1).optional(), +}); + +export const GenerateCodeExportInputSchema = z.object({ + spec: DesignSpecSchema, +}); + +export const generateDesignSpec = ( + input: z.infer +): DesignSpec => { + const parsed = GenerateDesignSpecInputSchema.parse(input); + return DesignSpecSchema.parse(createBaseSpec(parsed.prompt)); +}; + +export const iterateDesignSpec = ( + input: z.infer +): { spec: DesignSpec; summary: string } => { + const parsed = IterateDesignSpecInputSchema.parse(input); + const nextSpec = structuredClone(parsed.currentSpec); + const primaryColor = getColorFromPrompt(parsed.prompt); + + if (primaryColor) { + nextSpec.theme.colors.primary = primaryColor; + } + + if (parsed.prompt.toLowerCase().includes("dark")) { + nextSpec.theme.colors.background = "#020617"; + nextSpec.theme.colors.text = "#F8FAFC"; + nextSpec.intent.styleKeywords = [ + ...new Set([...nextSpec.intent.styleKeywords, "dark"]), + ]; + } + + nextSpec.sections = nextSpec.sections.map((section) => { + if (parsed.targetSectionId && section.id !== parsed.targetSectionId) { + return section; + } + + return applySectionMutation(section, parsed.prompt); + }); + + return { + spec: DesignSpecSchema.parse(nextSpec), + summary: `Applied updates from prompt: "${parsed.prompt}"`, + }; +}; + +export const generateCodeExport = ( + input: z.infer +): string => { + const parsed = GenerateCodeExportInputSchema.parse(input); + + const hero = parsed.spec.sections.find((section) => section.type === "hero"); + + return `export default function Website() { + return ( +
+
+

${hero?.title ?? "Website"}

+ ${hero ? `

${hero.subtitle}

` : ""} + ${hero ? `` : ""} +
+
+ ); +}`; +}; diff --git a/packages/elements/src/types/designer.ts b/packages/elements/src/types/designer.ts new file mode 100644 index 00000000..c9d3947a --- /dev/null +++ b/packages/elements/src/types/designer.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; + +export interface DesignIntent { + targetAudience: string; + styleKeywords: string[]; +} + +export interface ThemeSpec { + borderRadius: number; + fontFamily: string; + spacing: number; + colors: { + primary: string; + secondary: string; + background: string; + text: string; + }; +} + +export interface LayoutPage { + id: string; + name: string; + path: string; + sectionIds: string[]; +} + +export interface HeroSection { + id: string; + type: "hero"; + title: string; + subtitle: string; + ctaText: string; +} + +export interface FeaturesSection { + id: string; + type: "features"; + title: string; + items: string[]; +} + +export interface PricingSection { + id: string; + type: "pricing"; + title: string; + tiers: { + name: string; + price: string; + description: string; + }[]; +} + +export interface FooterSection { + id: string; + type: "footer"; + text: string; +} + +export type DesignSection = + | HeroSection + | FeaturesSection + | PricingSection + | FooterSection; + +export interface DesignSpec { + intent: DesignIntent; + layout: LayoutPage[]; + sections: DesignSection[]; + theme: ThemeSpec; +} + +const HexColorSchema = z.string().regex(/^#(?:[\dA-Fa-f]{3}){1,2}$/); + +export const DesignIntentSchema = z.object({ + styleKeywords: z.array(z.string().min(1)).min(1), + targetAudience: z.string().min(1), +}); + +export const ThemeSpecSchema = z.object({ + borderRadius: z.number().min(0).max(64), + colors: z.object({ + background: HexColorSchema, + primary: HexColorSchema, + secondary: HexColorSchema, + text: HexColorSchema, + }), + fontFamily: z.string().min(1), + spacing: z.number().min(0).max(64), +}); + +export const LayoutPageSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + path: z.string().min(1), + sectionIds: z.array(z.string().min(1)).min(1), +}); + +const HeroSectionSchema = z.object({ + ctaText: z.string().min(1), + id: z.string().min(1), + subtitle: z.string().min(1), + title: z.string().min(1), + type: z.literal("hero"), +}); + +const FeaturesSectionSchema = z.object({ + id: z.string().min(1), + items: z.array(z.string().min(1)).min(1), + title: z.string().min(1), + type: z.literal("features"), +}); + +const PricingSectionSchema = z.object({ + id: z.string().min(1), + tiers: z + .array( + z.object({ + description: z.string().min(1), + name: z.string().min(1), + price: z.string().min(1), + }) + ) + .min(1), + title: z.string().min(1), + type: z.literal("pricing"), +}); + +const FooterSectionSchema = z.object({ + id: z.string().min(1), + text: z.string().min(1), + type: z.literal("footer"), +}); + +export const DesignSectionSchema = z.discriminatedUnion("type", [ + HeroSectionSchema, + FeaturesSectionSchema, + PricingSectionSchema, + FooterSectionSchema, +]); + +export const DesignSpecSchema = z.object({ + intent: DesignIntentSchema, + layout: z.array(LayoutPageSchema).min(1), + sections: z.array(DesignSectionSchema).min(1), + theme: ThemeSpecSchema, +}); diff --git a/packages/elements/src/website-designer.tsx b/packages/elements/src/website-designer.tsx new file mode 100644 index 00000000..81b392ad --- /dev/null +++ b/packages/elements/src/website-designer.tsx @@ -0,0 +1,28 @@ +export { PreviewRenderer } from "./components/WebsiteDesigner/preview-renderer"; +export { WebsiteDesigner } from "./components/WebsiteDesigner/website-designer"; +export { + generateCodeExport, + generateDesignSpec, + GenerateCodeExportInputSchema, + GenerateDesignSpecInputSchema, + iterateDesignSpec, + IterateDesignSpecInputSchema, +} from "./mcp/designer-tools"; +export { + DesignIntentSchema, + DesignSectionSchema, + DesignSpecSchema, + LayoutPageSchema, + ThemeSpecSchema, +} from "./types/designer"; +export type { + DesignIntent, + DesignSection, + DesignSpec, + FeaturesSection, + FooterSection, + HeroSection, + LayoutPage, + PricingSection, + ThemeSpec, +} from "./types/designer";