diff --git a/ui/src/components/studio/classifyIntent.test.ts b/ui/src/components/studio/classifyIntent.test.ts new file mode 100644 index 00000000..19d5052f --- /dev/null +++ b/ui/src/components/studio/classifyIntent.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { classifyIntent } from "./classifyIntent"; +import type { WorkshopSlug } from "./workshops"; + +describe("classifyIntent", () => { + it("returns null for empty or whitespace-only input", () => { + expect(classifyIntent("")).toBeNull(); + expect(classifyIntent(" ")).toBeNull(); + expect(classifyIntent("\n\t ")).toBeNull(); + }); + + it.each<[string, WorkshopSlug]>([ + // Convert — must be checked first + ["convert this pdf to markdown", "convert"], + ["can you convert it", "convert"], + ["from docx to pdf please", "convert"], + ["pdf to docx", "convert"], + ["transform html to md", "convert"], + + // Wallpapers + ["I need a 1920x1080 wallpaper of a forest", "wallpapers"], + ["make a desktop bg with neon lights", "wallpapers"], + ["generate wallpapers for my lock screen", "wallpapers"], + + // Diagrams + ["diagram of the auth flow", "diagrams"], + ["flowchart for the checkout process", "diagrams"], + ["mermaid sequence diagram", "diagrams"], + ["architecture diagram of the platform", "diagrams"], + + // Icons + ["I want an icon set for my app", "icons"], + ["svg icon of a rocket", "icons"], + ["icon pack please", "icons"], + + // Themes + ["build me a theme from #166534", "themes"], + ["color palette based on volt", "themes"], + ["generate a palette", "themes"], + + // Brand kits (checked before documents — logo beats document) + ["create a brand identity for an AI startup", "brand-kits"], + ["I need a logo", "brand-kits"], + ["style guide please", "brand-kits"], + + // Social + ["twitter post about our launch", "social"], + ["linkedin carousel on hiring", "social"], + ["instagram post for the team", "social"], + + // Documents (must come after convert/brand) + ["generate a quarterly report", "documents"], + ["one-pager about the product", "documents"], + ["an invoice for client acme", "documents"], + ])("routes %j → %s", (prompt, expectedSlug) => { + const result = classifyIntent(prompt); + expect(result).not.toBeNull(); + expect(result?.slug).toBe(expectedSlug); + expect(result?.prefilledPrompt).toBe(prompt); + }); + + it.each([ + "random musing about life", + "what's the weather today", + "tell me a joke", + "remind me to buy milk", + "hello world", + ])("returns null for unclassified prompt %j", (prompt) => { + expect(classifyIntent(prompt)).toBeNull(); + }); + + it("preserves the original prompt verbatim in prefilledPrompt (no lowercasing)", () => { + const prompt = "Diagram Of The Auth Flow, MERMAID style"; + const result = classifyIntent(prompt); + expect(result?.prefilledPrompt).toBe(prompt); + expect(result?.slug).toBe("diagrams"); + }); +}); diff --git a/ui/src/components/studio/classifyIntent.ts b/ui/src/components/studio/classifyIntent.ts new file mode 100644 index 00000000..add6c09e --- /dev/null +++ b/ui/src/components/studio/classifyIntent.ts @@ -0,0 +1,75 @@ +import type { WorkshopSlug } from "./workshops"; + +export interface IntentClassification { + slug: WorkshopSlug; + prefilledPrompt: string; +} + +/** + * Phase 10 — simple keyword-based intent classifier. + * + * Given a freeform prompt from the Studio prompt bar, return the workshop + * slug to route to and a copy of the prompt to pre-fill. Returns `null` if + * no workshop matches — callers should fall back to sending the prompt + * through to the Assistant. + * + * This is deliberately a single pure function with no state, no network, + * no LLM. Phase 14 may replace it with a real classifier; for Phase 10 we + * want deterministic routing and exhaustive unit-testable behavior. + * + * Ordering matters: more specific patterns (convert with explicit formats, + * wallpaper, diagram, icon) are checked first, so that "convert this pdf + * to docx" doesn't accidentally fall through the `documents` branch. + */ +export function classifyIntent(prompt: string): IntentClassification | null { + const lower = prompt.toLowerCase().trim(); + if (!lower) return null; + + // Convert — most specific first: explicit "convert X to Y", "from X to Y", + // or any of the common file-format keywords that strongly imply conversion. + if ( + /\bconvert\b/.test(lower) || + /\bfrom\s+\w+\s+to\s+\w+\b/.test(lower) || + /\b(pdf|docx?|md|markdown|html|rtf|odt|epub|txt)\s+to\s+\w+/.test(lower) || + /\bto\s+(pdf|docx?|md|markdown|html|rtf|odt|epub|txt)\b/.test(lower) + ) { + return { slug: "convert", prefilledPrompt: prompt }; + } + + // Wallpaper + if (/\bwallpaper(s)?\b|background\s+image|desktop\s+bg|lock\s+screen/.test(lower)) { + return { slug: "wallpapers", prefilledPrompt: prompt }; + } + + // Diagram + if (/\b(diagram|flowchart|sequence\s+diagram|mermaid|architecture\s+diagram|uml)\b/.test(lower)) { + return { slug: "diagrams", prefilledPrompt: prompt }; + } + + // Icons (note: "icon" must match as a whole word, not "iconic") + if (/\bicon(s|\s+set|\s+pack)?\b|svg\s+icon/.test(lower)) { + return { slug: "icons", prefilledPrompt: prompt }; + } + + // Themes / palettes + if (/\btheme(s)?\b|color\s+palette|colour\s+palette|\bpalette\b/.test(lower)) { + return { slug: "themes", prefilledPrompt: prompt }; + } + + // Brand kits (before documents — "logo" and "brand identity" beat "document") + if (/\b(brand|logo|identity|style\s+guide)\b/.test(lower)) { + return { slug: "brand-kits", prefilledPrompt: prompt }; + } + + // Social + if (/\b(social|tweet|post|instagram|linkedin|twitter|x\.com|carousel)\b/.test(lower)) { + return { slug: "social", prefilledPrompt: prompt }; + } + + // Documents — PDF/report/invoice/one-pager (after convert so "pdf to X" routes to convert) + if (/\b(pdf|document|invoice|report|one-?pager|whitepaper|resume|cv)\b/.test(lower)) { + return { slug: "documents", prefilledPrompt: prompt }; + } + + return null; +} diff --git a/ui/src/components/studio/workshops.test.ts b/ui/src/components/studio/workshops.test.ts new file mode 100644 index 00000000..c5118e1b --- /dev/null +++ b/ui/src/components/studio/workshops.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { WORKSHOPS, findWorkshop, type WorkshopSlug } from "./workshops"; + +describe("WORKSHOPS data", () => { + it("defines exactly 8 workshops in the Phase 10 canonical order", () => { + const slugs = WORKSHOPS.map((w) => w.slug); + expect(slugs).toEqual([ + "diagrams", + "icons", + "themes", + "wallpapers", + "documents", + "brand-kits", + "social", + "convert", + ] satisfies WorkshopSlug[]); + }); + + it("has unique slugs", () => { + const slugs = WORKSHOPS.map((w) => w.slug); + expect(new Set(slugs).size).toBe(slugs.length); + }); + + it("has a title, subtitle, icon, and componentKey for every workshop", () => { + for (const w of WORKSHOPS) { + expect(w.title).toMatch(/^[A-Z ]+$/); + expect(w.subtitle.length).toBeGreaterThan(0); + // Lucide icons are React forwardRef components (object), not plain functions. + expect(["function", "object"]).toContain(typeof w.icon); + expect(w.icon).not.toBeNull(); + expect(w.componentKey.length).toBeGreaterThan(0); + } + }); + + it("uses uppercase titles only", () => { + for (const w of WORKSHOPS) { + expect(w.title).toBe(w.title.toUpperCase()); + } + }); + + it("has unique componentKeys", () => { + const keys = WORKSHOPS.map((w) => w.componentKey); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("findWorkshop", () => { + it("returns the workshop definition for a known slug", () => { + const w = findWorkshop("diagrams"); + expect(w).not.toBeNull(); + expect(w?.title).toBe("DIAGRAMS"); + }); + + it("returns null for an unknown slug", () => { + expect(findWorkshop("nope")).toBeNull(); + expect(findWorkshop("")).toBeNull(); + }); +}); diff --git a/ui/src/components/studio/workshops.ts b/ui/src/components/studio/workshops.ts new file mode 100644 index 00000000..07938642 --- /dev/null +++ b/ui/src/components/studio/workshops.ts @@ -0,0 +1,110 @@ +import { + Workflow, + Shapes, + Palette, + Image as ImageIcon, + FileText, + Award, + Share2, + Repeat, +} from "lucide-react"; +import type { ComponentType } from "react"; + +/** + * Phase 10 — single source-of-truth for Studio workshops. + * + * The Studio home grid and the workshop detail view both consume this + * definition. Adding a new workshop means appending an entry here and + * wiring a component in StudioWorkshopDetail's WORKSHOP_COMPONENTS map. + */ + +export type WorkshopSlug = + | "diagrams" + | "icons" + | "themes" + | "wallpapers" + | "documents" + | "brand-kits" + | "social" + | "convert"; + +export interface WorkshopDefinition { + slug: WorkshopSlug; + /** Inter 700 uppercase — e.g. "DIAGRAMS". */ + title: string; + /** Inter 400 silver — single line. */ + subtitle: string; + /** Lucide icon component, rendered at 32x32 in volt, top-right of the card. */ + icon: ComponentType<{ className?: string }>; + /** + * Key used by StudioWorkshopDetail to look up the concrete generator + * component. Phase 10 does not rewrite any generator internals — each + * key resolves to one of the existing ContentStudio generator panels, + * or (for `convert`) to the legacy ConvertPanel folded in from + * `/convert`. + */ + componentKey: string; +} + +export const WORKSHOPS: WorkshopDefinition[] = [ + { + slug: "diagrams", + title: "DIAGRAMS", + subtitle: "Mermaid → rendered SVG", + icon: Workflow, + componentKey: "diagram", + }, + { + slug: "icons", + title: "ICONS", + subtitle: "SVG sets from description", + icon: Shapes, + componentKey: "icon", + }, + { + slug: "themes", + title: "THEMES", + subtitle: "Color → full palette", + icon: Palette, + componentKey: "theme", + }, + { + slug: "wallpapers", + title: "WALLPAPERS", + subtitle: "Desktop, mobile, banners", + icon: ImageIcon, + componentKey: "wallpaper", + }, + { + slug: "documents", + title: "DOCUMENTS", + subtitle: "PDF reports, invoices", + icon: FileText, + componentKey: "document", + }, + { + slug: "brand-kits", + title: "BRAND KITS", + subtitle: "Full brand identity", + icon: Award, + componentKey: "brand", + }, + { + slug: "social", + title: "SOCIAL", + subtitle: "Platform-ready posts", + icon: Share2, + componentKey: "social", + }, + { + slug: "convert", + title: "CONVERT", + subtitle: "File format conversion", + icon: Repeat, + componentKey: "convert", + }, +]; + +export function findWorkshop(slug: string): WorkshopDefinition | null { + return WORKSHOPS.find((w) => w.slug === slug) ?? null; +}