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` +
+ + 🪄 + MakeReal + +
+ +
+
+
+
+ + + + ${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 = ` +
+
+ Generating HTML... +
+ `; + } + + #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