Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/docs/app/[lang]/website-designer/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { WebsiteDesigner } from "@repo/elements/website-designer";

export default function WebsiteDesignerPage() {
return (
<div className="mx-auto max-w-7xl px-6 py-10">
<h1 className="mb-6 font-semibold text-2xl">Website Designer</h1>
<WebsiteDesigner />
</div>
);
}
37 changes: 37 additions & 0 deletions apps/docs/app/api/chat/tools.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
45 changes: 45 additions & 0 deletions packages/elements/__tests__/designer-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
94 changes: 94 additions & 0 deletions packages/elements/__tests__/website-designer.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div role="log" {...props}>
{children}
</div>
);

const StickyContent = ({ children, ...props }: MockProps) => (
<div {...props}>{children}</div>
);

return {
StickToBottomContent: StickyContent,
StickToBottomMock: StickyMock,
};
});

// oxlint-disable-next-line typescript-eslint(consistent-type-imports)
vi.mock<typeof import("use-stick-to-bottom")>(
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(<WebsiteDesigner />);

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(<WebsiteDesigner />);

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();
});
});
});
6 changes: 3 additions & 3 deletions packages/elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
159 changes: 159 additions & 0 deletions packages/elements/src/components/WebsiteDesigner/preview-renderer.tsx
Original file line number Diff line number Diff line change
@@ -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<DesignSection, { type: "hero" }>;
}) => (
<section
className="border p-6"
data-testid={section.id}
style={{ borderRadius: "var(--radius-size)" }}
>
<h1 className="font-semibold text-3xl">{section.title}</h1>
<p className="mt-2 text-sm opacity-90">{section.subtitle}</p>
<button
className="mt-4 rounded-md px-3 py-2 text-sm"
style={{
backgroundColor: "var(--primary-color)",
color: "var(--background-color)",
}}
type="button"
>
{section.ctaText}
</button>
</section>
);

const FeaturesBlock = ({
section,
}: {
section: Extract<DesignSection, { type: "features" }>;
}) => (
<section
className="border p-6"
data-testid={section.id}
style={{ borderRadius: "var(--radius-size)" }}
>
<h2 className="font-semibold text-2xl">{section.title}</h2>
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm">
{section.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
);

const PricingBlock = ({
section,
}: {
section: Extract<DesignSection, { type: "pricing" }>;
}) => (
<section
className="border p-6"
data-testid={section.id}
style={{ borderRadius: "var(--radius-size)" }}
>
<h2 className="font-semibold text-2xl">{section.title}</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{section.tiers.map((tier) => (
<div
className="border p-4"
key={tier.name}
style={{ borderRadius: "var(--radius-size)" }}
>
<p className="font-medium">{tier.name}</p>
<p className="text-lg">{tier.price}</p>
<p className="text-muted-foreground text-sm">{tier.description}</p>
</div>
))}
</div>
</section>
);

const FooterBlock = ({
section,
}: {
section: Extract<DesignSection, { type: "footer" }>;
}) => (
<footer
className="border-t pt-4 text-center text-xs"
data-testid={section.id}
>
{section.text}
</footer>
);

const SectionRenderer = ({ section }: { section: DesignSection }) => {
switch (section.type) {
case "hero": {
return <HeroBlock section={section} />;
}
case "features": {
return <FeaturesBlock section={section} />;
}
case "pricing": {
return <PricingBlock section={section} />;
}
case "footer": {
return <FooterBlock section={section} />;
}
default: {
return null;
}
}
};

const PreviewSkeleton = () => (
<div className="space-y-4 p-6" data-testid="preview-skeleton">
<div className="h-28 animate-pulse rounded-lg bg-muted" />
<div className="h-24 animate-pulse rounded-lg bg-muted" />
<div className="h-24 animate-pulse rounded-lg bg-muted" />
</div>
);

export const PreviewRenderer = ({ spec, loading }: PreviewRendererProps) => {
if (loading) {
return <PreviewSkeleton />;
}

if (!spec) {
return (
<div className="flex min-h-[420px] items-center justify-center p-6 text-muted-foreground text-sm">
Start by describing the website you want to design.
</div>
);
}

return (
<div
className={cn("min-h-[420px] space-y-4 p-6")}
style={
{
"--background-color": spec.theme.colors.background,
"--primary-color": spec.theme.colors.primary,
"--radius-size": `${spec.theme.borderRadius}px`,
"--secondary-color": spec.theme.colors.secondary,
"--spacing-size": `${spec.theme.spacing}px`,
"--text-color": spec.theme.colors.text,
backgroundColor: "var(--background-color)",
color: "var(--text-color)",
fontFamily: spec.theme.fontFamily,
gap: "var(--spacing-size)",
} as CSSProperties
}
>
{spec.sections.map((section) => (
<SectionRenderer key={section.id} section={section} />
))}
</div>
);
};
Loading