From 6b1efa587ed087986e76879f5a42cdf4c6efba82 Mon Sep 17 00:00:00 2001 From: jerelvelarde Date: Wed, 25 Mar 2026 16:50:11 -0700 Subject: [PATCH 1/3] feat: add download and copy-as-code for generated components Add the ability to download any generated visualization as a standalone HTML file (with animations preserved) and copy the source code to clipboard. This addresses the artifact export story needed for the upcoming component gallery. - New export-utils.ts with assembleStandaloneHtml (widgets), chartToStandaloneHtml (bar/pie charts), and triggerDownload - Download and copy buttons added to SaveTemplateOverlay, visible on all generated components (widgets, bar charts, pie charts) - Export SVG_CLASSES_CSS and FORM_STYLES_CSS from widget-renderer - Update demo-gallery plan with Variant-style layout and #55 context Closes #14 Closes #42 --- .chalk/plans/demo-gallery.md | 156 ++++++++++++++++ .../components/generative-ui/export-utils.ts | 175 ++++++++++++++++++ .../generative-ui/save-template-overlay.tsx | 154 +++++++++++++-- .../generative-ui/widget-renderer.tsx | 4 +- 4 files changed, 471 insertions(+), 18 deletions(-) create mode 100644 .chalk/plans/demo-gallery.md create mode 100644 apps/app/src/components/generative-ui/export-utils.ts diff --git a/.chalk/plans/demo-gallery.md b/.chalk/plans/demo-gallery.md new file mode 100644 index 0000000..8a949e1 --- /dev/null +++ b/.chalk/plans/demo-gallery.md @@ -0,0 +1,156 @@ +# Demo Gallery — Component Gallery with Download + +## Context + +The current template system (a drawer of raw HTML strings) doesn't communicate the value of agent-generated UI. We're replacing it with a **gallery of curated, saved conversation outputs** — each item is a snapshot: **1 user message → 1 generated component**. This lets visitors immediately see what the agent produces and interact with real outputs. + +Relates to issues #49, #55. Depends on #14/#42 (download/copy-as-code) for the export utilities. + +### Gallery items showcase two axes: + +**1. Explainers & Visualizations** — Educational/explanatory generated components (e.g. "How a Plane Flies" interactive 3D explainer, binary search step-through, solar system model, neural network forward pass animation). Demonstrates the agent's ability to generate rich, interactive educational content from a simple prompt. + +**2. Custom UI & Styled Components** — Generated components matching a specific design language or brand style (e.g. Clippy-style assistant UI, Spotify-inspired music dashboard, invoice card in a specific design system, themed dashboards). Demonstrates that generated UI isn't generic — the agent can produce components that feel intentionally designed. + +### Each gallery item includes: +- The original user prompt (1 message) +- The generated component output (rendered, interactive) +- Ability to **download** the generated component (via `export-utils.ts` from #14/#42) + +## Layout — Variant-style (reference: variant.com) + +**Current:** Chat is the main view. Template drawer slides over from right. + +**New:** Inspired by variant.com — a fixed left panel with hero text + chat input, and a scrollable masonry grid of generated components filling the rest of the viewport. + +``` ++--------------------+--------------------------------------------+ +| Logo | | +| | [Card] [Card - tall] [Card] | +| Endless generated | [Card - wide] [Card] | +| UIs for your | [Card] [Card] [Card - tall] | +| ideas, just scroll.| [Card] [Card - wide] | +| | [Card - tall] [Card] [Card] | +| Description text | ... infinite scroll ... | +| | | +| [Sign up] [Surprise| | +| me] | | +| | | +| +----------------+ | | +| | Chat input... | | | +| +----------------+ | | ++--------------------+--------------------------------------------+ +``` + +### Key layout properties: +- **Left panel** (~340px, fixed): Logo, hero tagline, description, CTAs ("Sign up / Sign in", "Surprise me"), and a chat input pinned to the bottom. This panel does NOT scroll. +- **Right area** (fills remaining width): Masonry grid of gallery cards that scrolls independently. Cards vary in size based on their content aspect ratio. +- **Cards**: Dark themed, rounded corners, show live interactive iframe previews of generated components. Each card has a hover overlay with download button and "Try it" action. +- **No category filter pills**: The masonry grid itself communicates variety. Items from both axes (explainers + styled UI) are interleaved. +- **Chat input**: Lives at the bottom of the left panel (not a separate side panel). Typing a prompt generates a new component that appears in the grid. +- **Dark theme**: The overall page uses a dark background to make the component previews pop. + +## Files to Create + +### 1. `apps/app/src/components/demo-gallery/gallery-data.ts` +Gallery item definitions. Each item is a conversation snapshot: 1 user prompt → 1 generated component. + +**Interface:** +```ts +export interface GalleryItem { + id: string; + title: string; + description: string; + axis: "explainer" | "styled"; // which showcase axis + prompt: string; // the original user message + html?: string; // generated HTML output (for live preview + download) + component_type?: string; + component_data?: Record; + size?: "normal" | "tall" | "wide"; // masonry size hint +} +``` + +**Curated items (~10), interleaved from both axes:** + +*Explainers & Visualizations:* +- How a Plane Flies — interactive 3D explainer +- Binary Search — step-through visualization +- Solar System — orbiting planets +- Neural Network — animated forward pass +- Sorting Comparison — bubble sort vs quicksort + +*Custom UI & Styled Components:* +- Weather Dashboard (reuse seed HTML) +- KPI Dashboard (reuse seed HTML) +- Invoice Card (reuse seed HTML) +- Pomodoro Timer +- Bike Battery Widget (like variant.com reference) + +Items with pre-rendered `html` show live iframe previews. Items without show a styled placeholder with the prompt text. + +### 2. `apps/app/src/components/demo-gallery/gallery-card.tsx` +Masonry card component. Dark themed, rounded corners. +- Shows live iframe preview (scaled down) of the generated component +- Hover overlay: download button (uses `export-utils.ts`), "Try it" button +- Card `size` prop controls CSS grid span (normal=1x1, tall=1x2, wide=2x1) + +### 3. `apps/app/src/components/demo-gallery/index.tsx` +Main gallery layout: fixed left panel + scrollable masonry grid. +- Left panel: logo, hero text, CTAs, chat input at bottom +- Right area: CSS grid masonry of `GalleryCard` components +- No category filters — items from both axes interleaved for variety + +## Files to Modify + +### 4. `apps/app/src/app/page.tsx` +Major layout restructure: +- Replace current `ExampleLayout` + `CopilotChat` with the new gallery layout +- The gallery component (`demo-gallery/index.tsx`) becomes the full-page view +- Chat input is embedded in the left panel, not a separate component +- "Try it" on a card sends the prompt to the agent and scrolls to / highlights the new output +- "Surprise me" button picks a random prompt from gallery data and sends it + +### 5. `apps/app/src/components/template-library/seed-templates.ts` +Keep this file — the HTML strings are reused by `gallery-data.ts` for items that have live previews. Import from here rather than duplicating. + +### 6. `apps/app/src/hooks/use-example-suggestions.tsx` +May be replaced or simplified — the gallery itself serves as the suggestion surface now. + +## Files to Remove + +### 7. `apps/app/src/components/template-library/index.tsx` and `template-card.tsx` +The template drawer is replaced by the gallery. `save-template-overlay.tsx` stays (it serves the in-chat widget interaction and now includes download/copy buttons from #14/#42). + +## Dependencies + +### Download/export (prerequisite — issues #14/#42) +The gallery cards need download buttons. The `export-utils.ts` module (from the download PR) provides `assembleStandaloneHtml`, `triggerDownload`, and `slugify`. The gallery card hover overlay will import these directly. + +### Sending messages programmatically +Need to verify the CopilotKit v2 API for programmatic message sending. Options: +1. `agent.sendMessage(text)` — if available in v2 +2. `agent.addMessage({ role: "user", content: text }) + agent.runAgent()` +3. Fallback: programmatically set textarea value and dispatch submit + +## Implementation Order + +1. **(Prerequisite)** Land download/copy-as-code PR (#14/#42) — provides `export-utils.ts` +2. Create `gallery-data.ts` with curated items from both axes +3. Create `gallery-card.tsx` — dark card with iframe preview + hover overlay +4. Create `demo-gallery/index.tsx` — left panel + masonry grid layout +5. Restructure `page.tsx` — replace current layout with gallery +6. Wire up "Try it" → send prompt to agent +7. Wire up "Surprise me" → random prompt +8. Wire up download button on cards (reuse `export-utils.ts`) +9. Clean up old template drawer files + +## Verification + +1. `pnpm dev:app` — app builds and renders gallery as default view +2. Gallery shows masonry grid of ~10 cards with live iframe previews +3. Left panel has hero text, CTAs, and chat input +4. Hovering a card shows download + "Try it" overlay +5. Clicking "Try it" sends the prompt and generates a new component +6. Download button produces a standalone `.html` file with working animations +7. "Surprise me" picks a random prompt +8. Dark theme looks clean, cards pop against background diff --git a/apps/app/src/components/generative-ui/export-utils.ts b/apps/app/src/components/generative-ui/export-utils.ts new file mode 100644 index 0000000..d335831 --- /dev/null +++ b/apps/app/src/components/generative-ui/export-utils.ts @@ -0,0 +1,175 @@ +import { THEME_CSS } from "./widget-renderer"; +import { SVG_CLASSES_CSS, FORM_STYLES_CSS } from "./widget-renderer"; + +const CHART_COLORS = [ + "#3b82f6", + "#8b5cf6", + "#ec4899", + "#f59e0b", + "#10b981", + "#06b6d4", + "#f97316", +]; + +// Import map matching widget-renderer's assembleShell — allows widgets that +// use bare specifiers (e.g. `import * as THREE from "three"`) to work standalone. +const IMPORT_MAP = ``; + +/** + * Wrap a raw HTML fragment (the same string passed to WidgetRenderer) + * in a standalone document that works when opened in a browser. + */ +export function assembleStandaloneHtml(html: string, title: string): string { + return ` + + + + + ${escapeHtml(title)} + ${IMPORT_MAP} + + + +
+ ${html} +
+ + +`; +} + +/** + * Generate a standalone HTML file that renders a chart using Chart.js from CDN. + */ +export function chartToStandaloneHtml( + type: "bar" | "pie", + data: { title: string; description: string; data: Array<{ label: string; value: number }> } +): string { + const labels = JSON.stringify(data.data.map((d) => d.label)); + const values = JSON.stringify(data.data.map((d) => d.value)); + const colors = JSON.stringify( + data.data.map((_, i) => CHART_COLORS[i % CHART_COLORS.length]) + ); + + const chartConfig = + type === "bar" + ? `{ + type: 'bar', + data: { + labels: ${labels}, + datasets: [{ + data: ${values}, + backgroundColor: ${colors}, + borderRadius: 4, + }] + }, + options: { + responsive: true, + plugins: { + legend: { display: false }, + tooltip: { backgroundColor: '#1f2937', titleColor: '#fff', bodyColor: '#fff', cornerRadius: 8, padding: 10 } + }, + scales: { + x: { grid: { display: false } }, + y: { grid: { color: 'rgba(0,0,0,0.06)' } } + } + } + }` + : `{ + type: 'pie', + data: { + labels: ${labels}, + datasets: [{ + data: ${values}, + backgroundColor: ${colors}, + }] + }, + options: { + responsive: true, + plugins: { + legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true } }, + tooltip: { backgroundColor: '#1f2937', titleColor: '#fff', bodyColor: '#fff', cornerRadius: 8, padding: 10 } + } + } + }`; + + return ` + + + + + ${escapeHtml(data.title)} + + + +

${escapeHtml(data.title)}

+

${escapeHtml(data.description)}

+ + + + +`; +} + +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function triggerDownload(htmlString: string, filename: string): void { + const blob = new Blob([htmlString], { type: "text/html" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx index d1fbb15..d6e0ba5 100644 --- a/apps/app/src/components/generative-ui/save-template-overlay.tsx +++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx @@ -3,6 +3,12 @@ import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; import { useAgent } from "@copilotkit/react-core/v2"; import { SEED_TEMPLATES } from "@/components/template-library/seed-templates"; +import { + assembleStandaloneHtml, + chartToStandaloneHtml, + triggerDownload, + slugify, +} from "./export-utils"; type SaveState = "idle" | "input" | "saving" | "saved"; @@ -34,6 +40,7 @@ export function SaveTemplateOverlay({ const { agent } = useAgent(); const [saveState, setSaveState] = useState("idle"); const [templateName, setTemplateName] = useState(""); + const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); // Capture pending_template at mount time — it may be cleared by the agent later. // Uses ref (not state) to avoid an async re-render that would shift sibling positions @@ -94,6 +101,35 @@ export function SaveTemplateOverlay({ }, 400); }, [agent, templateName, title, description, html, componentData, componentType]); + const exportHtml = useMemo(() => { + if (componentType === "widgetRenderer" && html) { + return assembleStandaloneHtml(html, title); + } + if ((componentType === "barChart" || componentType === "pieChart") && componentData) { + const chartType = componentType === "barChart" ? "bar" : "pie"; + return chartToStandaloneHtml( + chartType, + componentData as { title: string; description: string; data: Array<{ label: string; value: number }> } + ); + } + return null; + }, [componentType, html, componentData, title]); + + const handleDownload = useCallback(() => { + if (!exportHtml) return; + const filename = `${slugify(title) || "visualization"}.html`; + triggerDownload(exportHtml, filename); + }, [exportHtml, title]); + + const handleCopy = useCallback(() => { + const textToCopy = componentType === "widgetRenderer" ? html : exportHtml; + if (!textToCopy) return; + navigator.clipboard.writeText(textToCopy).then(() => { + setCopyState("copied"); + setTimeout(() => setCopyState("idle"), 1800); + }); + }, [componentType, html, exportHtml]); + return (
{/* Save as Template button — hidden until content is ready */} @@ -196,23 +232,109 @@ export function SaveTemplateOverlay({
)} - {/* Idle: show save button (badge moved outside this container) */} + {/* Idle with matched template: show download/copy only */} + {saveState === "idle" && matchedTemplate && exportHtml && ( +
+ + +
+ )} + + {/* Idle without matched template: show all action buttons */} {saveState === "idle" && !matchedTemplate && ( - +
+ {exportHtml && ( + <> + + + + )} + +
)} diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx index c8bd429..43ccb19 100644 --- a/apps/app/src/components/generative-ui/widget-renderer.tsx +++ b/apps/app/src/components/generative-ui/widget-renderer.tsx @@ -97,7 +97,7 @@ export const THEME_CSS = ` `; // ─── Injected CSS: SVG Pre-Built Classes (Layer 4) ─────────────────── -const SVG_CLASSES_CSS = ` +export const SVG_CLASSES_CSS = ` svg text.t { font: 400 14px var(--font-sans); fill: var(--p); } svg text.ts { font: 400 12px var(--font-sans); fill: var(--s); } svg text.th { font: 500 14px var(--font-sans); fill: var(--p); } @@ -216,7 +216,7 @@ svg .c-red text.ts { fill: #A32D2D; } `; // ─── Injected CSS: Form Element Styles (Layer 5) ───────────────────── -const FORM_STYLES_CSS = ` +export const FORM_STYLES_CSS = ` * { box-sizing: border-box; margin: 0; } html { background: transparent; } From 608a2d3f8bf7572802ccf184512fe00c8b8fd39a Mon Sep 17 00:00:00 2001 From: jerelvelarde Date: Wed, 25 Mar 2026 21:55:34 -0700 Subject: [PATCH 2/3] refactor: replace action buttons with three-dot dropdown menu - Single three-dot (...) trigger button in top-right corner - Dropdown menu with: Copy to clipboard, Download file, Save as artifact - Overlay only visible on hover (hides when cursor leaves component) - Menu stays open while interacting, closes on outside click --- .../generative-ui/save-template-overlay.tsx | 195 +++++++++--------- 1 file changed, 100 insertions(+), 95 deletions(-) diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx index d6e0ba5..18d5163 100644 --- a/apps/app/src/components/generative-ui/save-template-overlay.tsx +++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; +import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from "react"; import { useAgent } from "@copilotkit/react-core/v2"; import { SEED_TEMPLATES } from "@/components/template-library/seed-templates"; import { @@ -41,6 +41,21 @@ export function SaveTemplateOverlay({ const [saveState, setSaveState] = useState("idle"); const [templateName, setTemplateName] = useState(""); const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); + const [menuOpen, setMenuOpen] = useState(false); + const [hovered, setHovered] = useState(false); + const menuRef = useRef(null); + + // Close menu on outside click + useEffect(() => { + if (!menuOpen) return; + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [menuOpen]); // Capture pending_template at mount time — it may be cleared by the agent later. // Uses ref (not state) to avoid an async re-render that would shift sibling positions @@ -79,6 +94,7 @@ export function SaveTemplateOverlay({ const handleSave = useCallback(() => { const name = templateName.trim() || title || "Untitled Template"; setSaveState("saving"); + setMenuOpen(false); const templates = agent.state?.templates || []; const newTemplate = { @@ -119,6 +135,7 @@ export function SaveTemplateOverlay({ if (!exportHtml) return; const filename = `${slugify(title) || "visualization"}.html`; triggerDownload(exportHtml, filename); + setMenuOpen(false); }, [exportHtml, title]); const handleCopy = useCallback(() => { @@ -126,18 +143,26 @@ export function SaveTemplateOverlay({ if (!textToCopy) return; navigator.clipboard.writeText(textToCopy).then(() => { setCopyState("copied"); + setMenuOpen(false); setTimeout(() => setCopyState("idle"), 1800); }); }, [componentType, html, exportHtml]); + // Show the trigger button only when hovered or menu/save-input is active + const showTrigger = ready && (hovered || menuOpen || saveState === "input" || saveState === "saving" || saveState === "saved"); + return ( -
- {/* Save as Template button — hidden until content is ready */} +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Action overlay — hidden until hover */}
{/* Saved confirmation */} @@ -182,7 +207,7 @@ export function SaveTemplateOverlay({
)} - {/* Name input */} + {/* Name input for save-as-template */} {saveState === "input" && (
)} - {/* Idle with matched template: show download/copy only */} - {saveState === "idle" && matchedTemplate && exportHtml && ( -
- + {/* Idle: three-dot trigger + dropdown menu */} + {saveState === "idle" && ( +
-
- )} - {/* Idle without matched template: show all action buttons */} - {saveState === "idle" && !matchedTemplate && ( -
- {exportHtml && ( - <> - + + + )} + {!matchedTemplate && ( + - - + Save as artifact + + )} +
)} -
)}
From a28b06953160c8f79061610b4bcb9dc0aaef91e7 Mon Sep 17 00:00:00 2001 From: jerelvelarde Date: Wed, 25 Mar 2026 21:57:32 -0700 Subject: [PATCH 3/3] refactor: horizontal dots menu, remove save-as-artifact option - Change three-dot trigger from vertical to horizontal orientation - Remove "Save as artifact" menu item and all save-related state/UI - Menu now shows only: Copy to clipboard, Download file - Clean up unused imports (useAgent, SEED_TEMPLATES) --- .../generative-ui/save-template-overlay.tsx | 308 +++--------------- 1 file changed, 50 insertions(+), 258 deletions(-) diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx index 18d5163..4614053 100644 --- a/apps/app/src/components/generative-ui/save-template-overlay.tsx +++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx @@ -1,8 +1,6 @@ "use client"; import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from "react"; -import { useAgent } from "@copilotkit/react-core/v2"; -import { SEED_TEMPLATES } from "@/components/template-library/seed-templates"; import { assembleStandaloneHtml, chartToStandaloneHtml, @@ -10,36 +8,24 @@ import { slugify, } from "./export-utils"; -type SaveState = "idle" | "input" | "saving" | "saved"; - interface SaveTemplateOverlayProps { - /** Title used as default template name */ title: string; - /** Description stored with the template */ description: string; - /** Raw HTML to save (for widget renderer templates) */ html?: string; - /** Structured data to save (for chart templates) */ componentData?: Record; - /** The component type that produced this (e.g. "barChart", "pieChart", "widgetRenderer") */ componentType: string; - /** Whether content has finished rendering — button hidden until true */ ready?: boolean; children: ReactNode; } export function SaveTemplateOverlay({ title, - description, html, componentData, componentType, ready = true, children, }: SaveTemplateOverlayProps) { - const { agent } = useAgent(); - const [saveState, setSaveState] = useState("idle"); - const [templateName, setTemplateName] = useState(""); const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); const [menuOpen, setMenuOpen] = useState(false); const [hovered, setHovered] = useState(false); @@ -57,66 +43,6 @@ export function SaveTemplateOverlay({ return () => document.removeEventListener("mousedown", handleClick); }, [menuOpen]); - // Capture pending_template at mount time — it may be cleared by the agent later. - // Uses ref (not state) to avoid an async re-render that would shift sibling positions - // and cause React to remount the iframe, losing rendered 3D/canvas content. - const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined; - const sourceRef = useRef<{ id: string; name: string } | null>(null); - // eslint-disable-next-line react-hooks/refs -- one-time ref init during render (React-endorsed pattern) - if (pending?.id && !sourceRef.current) { - sourceRef.current = pending; // eslint-disable-line react-hooks/refs - } - - // Check if this content matches an existing template: - // 1. Exact HTML match (seed templates rendered as-is) - // 2. Source template captured from pending_template (applied templates with modified data) - const matchedTemplate = useMemo(() => { - // First check source template from apply flow - if (sourceRef.current) { // eslint-disable-line react-hooks/refs - const allTemplates = [ - ...SEED_TEMPLATES, - ...((agent.state?.templates as { id: string; name: string }[]) || []), - ]; - const source = allTemplates.find((t) => t.id === sourceRef.current!.id); // eslint-disable-line react-hooks/refs - if (source) return source; - } - // Then check exact HTML match - if (!html) return null; - const normalise = (s: string) => s.replace(/\s+/g, " ").trim(); - const norm = normalise(html); - const allTemplates = [ - ...SEED_TEMPLATES, - ...((agent.state?.templates as { id: string; name: string; html: string }[]) || []), - ]; - return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null; - }, [html, agent.state?.templates]); - - const handleSave = useCallback(() => { - const name = templateName.trim() || title || "Untitled Template"; - setSaveState("saving"); - setMenuOpen(false); - - const templates = agent.state?.templates || []; - const newTemplate = { - id: crypto.randomUUID(), - name, - description: description || title || "", - html: html || "", - component_type: componentType, - component_data: componentData || null, - data_description: "", - created_at: new Date().toISOString(), - version: 1, - }; - agent.setState({ ...agent.state, templates: [...templates, newTemplate] }); - - setTemplateName(""); - setTimeout(() => { - setSaveState("saved"); - setTimeout(() => setSaveState("idle"), 1800); - }, 400); - }, [agent, templateName, title, description, html, componentData, componentType]); - const exportHtml = useMemo(() => { if (componentType === "widgetRenderer" && html) { return assembleStandaloneHtml(html, title); @@ -148,8 +74,7 @@ export function SaveTemplateOverlay({ }); }, [componentType, html, exportHtml]); - // Show the trigger button only when hovered or menu/save-input is active - const showTrigger = ready && (hovered || menuOpen || saveState === "input" || saveState === "saving" || saveState === "saved"); + const showTrigger = ready && exportHtml && (hovered || menuOpen); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > - {/* Action overlay — hidden until hover */}
- {/* Saved confirmation */} - {saveState === "saved" && ( -
- - - - Saved! -
- )} - - {/* Saving spinner */} - {saveState === "saving" && ( -
+ - -
- )} + + + + + + - {/* Idle: three-dot trigger + dropdown menu */} - {saveState === "idle" && ( -
- - - {menuOpen && ( -
(e.currentTarget.style.background = "var(--color-background-secondary, #f5f5f5)")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} > - {exportHtml && ( - <> - - - - )} - {!matchedTemplate && ( - + {copyState === "copied" ? ( + + + + ) : ( + + + + )} -
- )} -
- )} -
- - {/* Template name badge — shown above widget when matched */} - {saveState === "idle" && matchedTemplate && ready && ( -
-
- - - - {matchedTemplate.name} -
+ {copyState === "copied" ? "Copied!" : "Copy to clipboard"} + + +
+ )}
- )} +
{children}