diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts
index b15282e7..64dd89af 100644
--- a/lib/canvas-tools.ts
+++ b/lib/canvas-tools.ts
@@ -527,6 +527,32 @@ registry.push({
actionLabel: (args) => `Generating ASCII art: ${args.prompt?.slice(0, 50) || args.pattern || "random"}`,
});
+// ── MakeReal (Sketch-to-HTML) Tool ──
+registry.push({
+ declaration: {
+ name: "create_makereal",
+ description: "Convert a sketch or wireframe into functional HTML/CSS code with live preview. Use when the user wants to turn a drawing into a working web page.",
+ parameters: {
+ type: "object",
+ properties: {
+ prompt: { type: "string", description: "Description of the UI to generate from the sketch (e.g. 'A login page with email and password fields')" },
+ framework: {
+ type: "string",
+ description: "CSS/JS framework to use",
+ enum: ["html", "tailwind", "react"],
+ },
+ },
+ required: ["prompt"],
+ },
+ },
+ tagName: "folk-makereal",
+ buildProps: (args) => ({
+ prompt: args.prompt,
+ ...(args.framework ? { framework: args.framework } : {}),
+ }),
+ actionLabel: (args) => `Opening MakeReal: ${args.prompt?.slice(0, 50) || "wireframe"}${(args.prompt?.length || 0) > 50 ? "..." : ""}`,
+});
+
// ── Design Agent Tool ──
registry.push({
declaration: {
diff --git a/lib/folk-makereal.ts b/lib/folk-makereal.ts
new file mode 100644
index 00000000..39435c17
--- /dev/null
+++ b/lib/folk-makereal.ts
@@ -0,0 +1,767 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: var(--rs-bg-surface, #fff);
+ color: var(--rs-text-primary, #1e293b);
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 560px;
+ min-height: 480px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #8b5cf6, #6366f1);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .toolbar-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
+ flex-wrap: wrap;
+ }
+
+ .tool-btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid var(--rs-border, rgba(255, 255, 255, 0.1));
+ border-radius: 6px;
+ background: var(--rs-bg-surface, #1e293b);
+ cursor: pointer;
+ font-size: 14px;
+ transition: all 0.15s;
+ }
+
+ .tool-btn:hover {
+ border-color: #8b5cf6;
+ }
+
+ .tool-btn.active {
+ border-color: #8b5cf6;
+ background: rgba(139, 92, 246, 0.15);
+ }
+
+ .color-swatch {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 2px solid var(--rs-border, rgba(255, 255, 255, 0.1));
+ cursor: pointer;
+ transition: transform 0.1s;
+ }
+
+ .color-swatch:hover {
+ transform: scale(1.2);
+ }
+
+ .color-swatch.active {
+ border-color: var(--rs-text-primary, #e2e8f0);
+ transform: scale(1.15);
+ }
+
+ .size-slider {
+ width: 80px;
+ accent-color: #8b5cf6;
+ }
+
+ .size-label {
+ font-size: 11px;
+ color: var(--rs-text-muted, #64748b);
+ min-width: 20px;
+ }
+
+ .split-area {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ min-height: 0;
+ }
+
+ .canvas-area {
+ flex: 1;
+ position: relative;
+ cursor: crosshair;
+ overflow: hidden;
+ min-width: 0;
+ }
+
+ .canvas-area canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ .result-area {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ background: var(--rs-bg-muted, #f8fafc);
+ border-left: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
+ min-width: 0;
+ }
+
+ .result-area iframe {
+ flex: 1;
+ width: 100%;
+ border: none;
+ background: #fff;
+ }
+
+ .result-area pre {
+ flex: 1;
+ margin: 0;
+ padding: 10px;
+ font-size: 11px;
+ overflow: auto;
+ background: #1e1e2e;
+ color: #cdd6f4;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .result-placeholder {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--rs-text-muted, #94a3b8);
+ text-align: center;
+ font-size: 12px;
+ padding: 12px;
+ }
+
+ .result-placeholder-icon {
+ font-size: 32px;
+ opacity: 0.4;
+ }
+
+ .result-toolbar {
+ display: flex;
+ gap: 4px;
+ padding: 4px 8px;
+ background: var(--rs-bg-surface, #fff);
+ border-bottom: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
+ font-size: 11px;
+ }
+
+ .result-toolbar button {
+ padding: 3px 8px;
+ border: 1px solid var(--rs-border, #e2e8f0);
+ border-radius: 4px;
+ background: var(--rs-bg-surface, #fff);
+ cursor: pointer;
+ font-size: 11px;
+ color: var(--rs-text-primary, #1e293b);
+ }
+
+ .result-toolbar button:hover {
+ background: var(--rs-bg-muted, #f1f5f9);
+ }
+
+ .result-toolbar button.active {
+ background: #8b5cf6;
+ color: white;
+ border-color: #8b5cf6;
+ }
+
+ .spinner-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.7);
+ gap: 8px;
+ z-index: 2;
+ }
+
+ .spinner {
+ width: 28px;
+ height: 28px;
+ border: 3px solid #e2e8f0;
+ border-top-color: #8b5cf6;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ .spinner-text {
+ font-size: 11px;
+ color: #64748b;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .prompt-bar {
+ display: flex;
+ gap: 6px;
+ padding: 8px 12px;
+ border-top: 1px solid var(--rs-border, rgba(255, 255, 255, 0.1));
+ align-items: center;
+ }
+
+ .prompt-input {
+ flex: 1;
+ padding: 6px 10px;
+ border: 2px solid var(--rs-input-border, #e2e8f0);
+ border-radius: 6px;
+ font-size: 12px;
+ outline: none;
+ font-family: inherit;
+ background: var(--rs-input-bg, #fff);
+ color: var(--rs-input-text, inherit);
+ }
+
+ .prompt-input:focus {
+ border-color: #8b5cf6;
+ }
+
+ .generate-btn {
+ padding: 6px 14px;
+ background: linear-gradient(135deg, #8b5cf6, #6366f1);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: opacity 0.2s;
+ }
+
+ .generate-btn:hover {
+ opacity: 0.9;
+ }
+
+ .generate-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .framework-select {
+ padding: 4px 8px;
+ border: 2px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 11px;
+ background: var(--rs-bg-surface, white);
+ cursor: pointer;
+ }
+`;
+
+const COLORS = ["#0f172a", "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#ffffff"];
+
+interface Stroke {
+ points: { x: number; y: number; pressure: number }[];
+ color: string;
+ size: number;
+ tool: string;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-makereal": FolkMakereal;
+ }
+}
+
+export class FolkMakereal extends FolkShape {
+ static override tagName = "folk-makereal";
+
+ static override portDescriptors = [
+ { name: "prompt", type: "text" as const, direction: "input" as const },
+ { name: "html", type: "text" as const, direction: "output" as const },
+ ];
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #strokes: Stroke[] = [];
+ #currentStroke: Stroke | null = null;
+ #canvas: HTMLCanvasElement | null = null;
+ #ctx: CanvasRenderingContext2D | null = null;
+ #color = "#0f172a";
+ #brushSize = 4;
+ #tool = "pen";
+ #isDrawing = false;
+ #isGenerating = false;
+ #framework = "html";
+ #lastHtml: string | null = null;
+ #showCode = false;
+ #promptInput: HTMLInputElement | null = null;
+ #generateBtn: HTMLButtonElement | null = null;
+ #resultArea: HTMLElement | null = null;
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+ ${COLORS.map((c) => ``).join("")}
+
+
+ 4
+
+
+
+
+
+
+
+
+
+ 🪄
+ Draw a wireframe and click Make Real
+
+
+
+
+
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) {
+ containerDiv.replaceWith(wrapper);
+ }
+
+ this.#canvas = wrapper.querySelector(".canvas-area canvas") as HTMLCanvasElement;
+ this.#ctx = this.#canvas.getContext("2d");
+ this.#promptInput = wrapper.querySelector(".prompt-input") as HTMLInputElement;
+ this.#generateBtn = wrapper.querySelector(".generate-btn") as HTMLButtonElement;
+ this.#resultArea = wrapper.querySelector(".result-area") as HTMLElement;
+
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+ const sizeSlider = wrapper.querySelector(".size-slider") as HTMLInputElement;
+ const sizeLabel = wrapper.querySelector(".size-label") as HTMLElement;
+ const canvasArea = wrapper.querySelector(".canvas-area") as HTMLElement;
+ const frameworkSelect = wrapper.querySelector(".framework-select") as HTMLSelectElement;
+
+ // Tool buttons
+ wrapper.querySelectorAll(".tool-btn").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const tool = (btn as HTMLElement).dataset.tool;
+ if (tool === "clear") {
+ this.#strokes = [];
+ this.#redraw();
+ return;
+ }
+ this.#tool = tool || "pen";
+ wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active"));
+ if (tool !== "clear") btn.classList.add("active");
+ });
+ });
+
+ // Color swatches
+ wrapper.querySelectorAll(".color-swatch").forEach((swatch) => {
+ swatch.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#color = (swatch as HTMLElement).dataset.color || "#0f172a";
+ wrapper.querySelectorAll(".color-swatch").forEach((s) => s.classList.remove("active"));
+ swatch.classList.add("active");
+ this.#tool = "pen";
+ wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active"));
+ wrapper.querySelector('[data-tool="pen"]')?.classList.add("active");
+ });
+ });
+
+ // Size slider
+ sizeSlider.addEventListener("input", (e) => {
+ e.stopPropagation();
+ this.#brushSize = parseInt(sizeSlider.value);
+ sizeLabel.textContent = sizeSlider.value;
+ });
+ sizeSlider.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ // Framework select
+ frameworkSelect.addEventListener("change", (e) => {
+ e.stopPropagation();
+ this.#framework = frameworkSelect.value;
+ });
+ frameworkSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ // Drawing events
+ this.#canvas.addEventListener("pointerdown", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ this.#isDrawing = true;
+ this.#canvas!.setPointerCapture(e.pointerId);
+ const pos = this.#getCanvasPos(e);
+ this.#currentStroke = {
+ points: [{ ...pos, pressure: e.pressure || 0.5 }],
+ color: this.#tool === "eraser" ? "#ffffff" : this.#color,
+ size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
+ tool: this.#tool,
+ };
+ });
+
+ this.#canvas.addEventListener("pointermove", (e) => {
+ if (!this.#isDrawing || !this.#currentStroke) return;
+ e.stopPropagation();
+ const pos = this.#getCanvasPos(e);
+ this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 });
+ this.#drawStroke(this.#currentStroke);
+ });
+
+ const endDraw = () => {
+ if (!this.#isDrawing) return;
+ this.#isDrawing = false;
+ if (this.#currentStroke && this.#currentStroke.points.length > 0) {
+ this.#strokes.push(this.#currentStroke);
+ }
+ this.#currentStroke = null;
+ };
+
+ this.#canvas.addEventListener("pointerup", endDraw);
+ this.#canvas.addEventListener("pointerleave", endDraw);
+
+ // Generate button
+ this.#generateBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#generate();
+ });
+
+ // Enter key in prompt
+ this.#promptInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ this.#generate();
+ }
+ });
+ this.#promptInput.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ // Size canvas on next frame
+ requestAnimationFrame(() => {
+ this.#resizeCanvas(canvasArea);
+ this.#redraw();
+ });
+
+ const ro = new ResizeObserver(() => {
+ this.#resizeCanvas(canvasArea);
+ this.#redraw();
+ });
+ ro.observe(canvasArea);
+
+ return root;
+ }
+
+ async #generate() {
+ const prompt = this.#promptInput?.value.trim() || "Convert this wireframe into a working UI";
+ if (this.#isGenerating || !this.#canvas) return;
+
+ this.#isGenerating = true;
+ if (this.#generateBtn) this.#generateBtn.disabled = true;
+ this.#showCode = false;
+ this.#renderLoading();
+
+ try {
+ const sketchImage = this.#canvas.toDataURL("image/jpeg", 0.85);
+
+ const response = await fetch("/api/makereal", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sketch_image: sketchImage,
+ prompt,
+ framework: this.#framework,
+ }),
+ });
+
+ if (!response.ok) {
+ const err = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error(err.error || `Generation failed: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+ if (!result.html) throw new Error("No HTML returned");
+
+ this.#lastHtml = result.html;
+ this.#renderResult();
+
+ this.dispatchEvent(new CustomEvent("html-generated", {
+ detail: { html: result.html, prompt },
+ }));
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : "Generation failed";
+ this.#renderError(msg);
+ } finally {
+ this.#isGenerating = false;
+ if (this.#generateBtn) this.#generateBtn.disabled = false;
+ }
+ }
+
+ #renderLoading() {
+ if (!this.#resultArea) return;
+ this.#resultArea.innerHTML = `
+
+ `;
+ }
+
+ #renderResult() {
+ if (!this.#resultArea || !this.#lastHtml) return;
+
+ const toolbar = `
+
+
+
+
+
`;
+
+ if (this.#showCode) {
+ this.#resultArea.innerHTML = `${toolbar}${this.#escapeHtml(this.#lastHtml)}`;
+ } else {
+ this.#resultArea.innerHTML = `${toolbar}`;
+ }
+
+ // Wire toolbar buttons
+ this.#resultArea.querySelector(".view-preview-btn")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#showCode = false;
+ this.#renderResult();
+ });
+ this.#resultArea.querySelector(".view-code-btn")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#showCode = true;
+ this.#renderResult();
+ });
+ this.#resultArea.querySelector(".copy-btn")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (this.#lastHtml) navigator.clipboard.writeText(this.#lastHtml);
+ });
+ this.#resultArea.querySelector(".open-btn")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (this.#lastHtml) {
+ const blob = new Blob([this.#lastHtml], { type: "text/html" });
+ window.open(URL.createObjectURL(blob), "_blank");
+ }
+ });
+ }
+
+ #renderError(msg: string) {
+ if (!this.#resultArea) return;
+ this.#resultArea.innerHTML = `
+
+ ⚠️
+ ${this.#escapeHtml(msg)}
+
+ `;
+ }
+
+ #resizeCanvas(container: HTMLElement) {
+ if (!this.#canvas) return;
+ const rect = container.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0) {
+ this.#canvas.width = rect.width * devicePixelRatio;
+ this.#canvas.height = rect.height * devicePixelRatio;
+ this.#canvas.style.width = rect.width + "px";
+ this.#canvas.style.height = rect.height + "px";
+ if (this.#ctx) {
+ this.#ctx.scale(devicePixelRatio, devicePixelRatio);
+ }
+ }
+ }
+
+ #getCanvasPos(e: PointerEvent): { x: number; y: number } {
+ const rect = this.#canvas!.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ };
+ }
+
+ #drawStroke(stroke: Stroke) {
+ if (!this.#ctx || stroke.points.length < 2) return;
+ const ctx = this.#ctx;
+
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.strokeStyle = stroke.color;
+
+ const len = stroke.points.length;
+ const p0 = stroke.points[len - 2];
+ const p1 = stroke.points[len - 1];
+ const pressure = (p0.pressure + p1.pressure) / 2;
+
+ ctx.lineWidth = stroke.size * (0.5 + pressure);
+ if (stroke.tool === "eraser") {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.lineWidth = stroke.size * 3;
+ } else {
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(p0.x, p0.y);
+ ctx.lineTo(p1.x, p1.y);
+ ctx.stroke();
+
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ #redraw() {
+ if (!this.#ctx || !this.#canvas) return;
+ const ctx = this.#ctx;
+ const w = this.#canvas.width / devicePixelRatio;
+ const h = this.#canvas.height / devicePixelRatio;
+
+ ctx.clearRect(0, 0, w, h);
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, w, h);
+
+ for (const stroke of this.#strokes) {
+ if (stroke.points.length < 2) continue;
+
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.strokeStyle = stroke.color;
+
+ if (stroke.tool === "eraser") {
+ ctx.globalCompositeOperation = "destination-out";
+ } else {
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ for (let i = 1; i < stroke.points.length; i++) {
+ const p0 = stroke.points[i - 1];
+ const p1 = stroke.points[i];
+ const pressure = (p0.pressure + p1.pressure) / 2;
+ ctx.lineWidth = stroke.size * (0.5 + pressure);
+
+ ctx.beginPath();
+ ctx.moveTo(p0.x, p0.y);
+ ctx.lineTo(p1.x, p1.y);
+ ctx.stroke();
+ }
+ }
+
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ #escapeAttr(text: string): string {
+ return text.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
+ }
+
+ static override fromData(data: Record): FolkMakereal {
+ const shape = FolkShape.fromData(data) as FolkMakereal;
+ return shape;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-makereal",
+ strokes: this.#strokes.map((s) => ({
+ points: s.points,
+ color: s.color,
+ size: s.size,
+ tool: s.tool,
+ })),
+ lastHtml: this.#lastHtml,
+ };
+ }
+
+ override applyData(data: Record): void {
+ super.applyData(data);
+ if (data.lastHtml && this.#resultArea) {
+ this.#lastHtml = data.lastHtml;
+ this.#renderResult();
+ }
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index 5d557982..a5bd4745 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -48,6 +48,7 @@ export * from "./folk-transcription";
export * from "./folk-splat";
export * from "./folk-blender";
export * from "./folk-drawfast";
+export * from "./folk-makereal";
export * from "./folk-freecad";
export * from "./folk-kicad";
export * from "./folk-design-agent";
diff --git a/server/index.ts b/server/index.ts
index 13f77caa..cedef69f 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1546,6 +1546,66 @@ Focus on: color palette, textures, composition patterns, mood, typography style,
}
});
+// MakeReal: sketch-to-HTML via Gemini vision
+app.post("/api/makereal", async (c) => {
+ if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
+
+ const { sketch_image, prompt, framework = "html" } = await c.req.json();
+ if (!sketch_image) return c.json({ error: "sketch_image required" }, 400);
+
+ const { GoogleGenerativeAI } = await import("@google/generative-ai");
+ const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
+ const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
+
+ // Extract base64 from data URL
+ const match = sketch_image.match(/^data:(image\/\w+);base64,(.+)$/);
+ if (!match) return c.json({ error: "Invalid image data URL" }, 400);
+
+ const frameworkInstructions: Record = {
+ html: "Use plain HTML and CSS only. No external dependencies.",
+ tailwind: "Use Tailwind CSS via CDN (). Use Tailwind utility classes for all styling.",
+ react: "Use React via CDN (react, react-dom, babel-standalone). Include a single