From d432326e7ae312f1e62cb2071ab00f4b134edad3 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:17:58 +0000 Subject: [PATCH] feat(nexus): add StudioWorkshopDetail page (phase 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-column workshop detail shell — params on the left (holds the existing generator panel), preview placeholder and action bar (Save/Export/Send to Assistant) on the right. Maps each workshop slug to one of the legacy ContentStudio generator panels, with ConvertPanel folded in for the convert workshop and a placeholder fallback for unknown slugs. Reads ?prompt= from the URL and surfaces it as a chip. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/StudioWorkshopDetail.test.tsx | 201 ++++++++++++ ui/src/pages/StudioWorkshopDetail.tsx | 360 +++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 ui/src/pages/StudioWorkshopDetail.test.tsx create mode 100644 ui/src/pages/StudioWorkshopDetail.tsx diff --git a/ui/src/pages/StudioWorkshopDetail.test.tsx b/ui/src/pages/StudioWorkshopDetail.test.tsx new file mode 100644 index 00000000..d746e75d --- /dev/null +++ b/ui/src/pages/StudioWorkshopDetail.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "@/lib/router"; + +// Mock all generator panels — Phase 10 does not rewrite any generator +// internals, and the real panels hit network. We only care that the +// StudioWorkshopDetail shell maps slugs to the right generator. +vi.mock("../components/DiagramGeneratePanel", () => ({ + DiagramGeneratePanel: ({ companyId }: { companyId: string }) => ( +
diagram:{companyId}
+ ), +})); +vi.mock("../components/IconGeneratePanel", () => ({ + IconGeneratePanel: ({ companyId }: { companyId: string }) => ( +
icon:{companyId}
+ ), +})); +vi.mock("../components/WallpaperGeneratePanel", () => ({ + WallpaperGeneratePanel: ({ companyId }: { companyId: string }) => ( +
wallpaper:{companyId}
+ ), +})); +vi.mock("../components/SocialPostPanel", () => ({ + SocialPostPanel: ({ companyId }: { companyId: string }) => ( +
social:{companyId}
+ ), +})); +vi.mock("../components/DocumentGeneratePanel", () => ({ + DocumentGeneratePanel: ({ companyId }: { companyId: string }) => ( +
document:{companyId}
+ ), +})); +vi.mock("../components/BrandKitPanel", () => ({ + BrandKitPanel: ({ companyId }: { companyId: string }) => ( +
brand:{companyId}
+ ), +})); +vi.mock("../components/ConvertPanel", () => ({ + ConvertPanel: ({ companyId }: { companyId: string }) => ( +
convert:{companyId}
+ ), +})); +vi.mock("../components/ThemeSeedInput", () => ({ + ThemeSeedInput: () =>
seed
, +})); +vi.mock("../components/ThemePaletteGrid", () => ({ + ThemePaletteGrid: () =>
, +})); +vi.mock("../components/ThemePreviewPanel", () => ({ + ThemePreviewPanel: () =>
, +})); +vi.mock("../components/ThemeExportTabs", () => ({ + ThemeExportTabs: () =>
, +})); +vi.mock("../components/ThemeApplyConfirmDialog", () => ({ + ThemeApplyConfirmDialog: () =>
, +})); +vi.mock("../hooks/useContentJob", () => ({ + useContentJob: () => ({ + status: "idle", + resultAssetId: null, + submit: () => {}, + }), +})); + +// Stub CompanyContext so the router's Link/useNavigate wrappers work +// without a CompanyProvider — IconRail's test uses the same stub. +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [], + selectedCompanyId: "acme", + selectedCompany: { id: "acme", issuePrefix: "NEX" }, + selectionSource: "bootstrap" as const, + loading: false, + error: null, + setSelectedCompanyId: () => {}, + reloadCompanies: async () => {}, + createCompany: async () => { + throw new Error("not implemented in test stub"); + }, + }), +})); + +// Load after the mocks are wired. +import { StudioWorkshopDetail } from "./StudioWorkshopDetail"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("StudioWorkshopDetail", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function render(slug: string, initialPath = `/NEX/content-studio/${slug}`) { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + } + + it("renders the diagrams workshop with the diagram generator in the params column", () => { + render("diagrams"); + expect(container.querySelector("[data-testid='mock-diagram-panel']")).not.toBeNull(); + expect(container.textContent).toContain("STUDIO"); + expect(container.textContent).toContain("DIAGRAMS"); + }); + + it("renders the convert workshop with the ConvertPanel folded in", () => { + render("convert"); + expect(container.querySelector("[data-testid='mock-convert-panel']")).not.toBeNull(); + expect(container.textContent).toContain("CONVERT"); + }); + + it("renders each non-theme generator for its corresponding slug", () => { + const cases = [ + ["diagrams", "mock-diagram-panel"], + ["icons", "mock-icon-panel"], + ["wallpapers", "mock-wallpaper-panel"], + ["documents", "mock-document-panel"], + ["brand-kits", "mock-brand-panel"], + ["social", "mock-social-panel"], + ["convert", "mock-convert-panel"], + ] as const; + for (const [slug, testid] of cases) { + render(slug); + expect( + container.querySelector(`[data-testid='${testid}']`), + `slug ${slug} should render ${testid}`, + ).not.toBeNull(); + act(() => { + root!.unmount(); + }); + root = null; + } + }); + + it("renders the theme workshop body (theme seed input visible)", () => { + render("themes"); + expect(container.querySelector("[data-testid='mock-theme-seed']")).not.toBeNull(); + }); + + it("renders a 404 fallback for an unknown slug", () => { + render("not-a-real-workshop", "/NEX/content-studio/not-a-real-workshop"); + expect(container.querySelector("[data-testid='workshop-not-found']")).not.toBeNull(); + expect(container.textContent).toContain("Unknown workshop"); + expect(container.textContent).toContain("not-a-real-workshop"); + }); + + it("renders the action bar with Save, Export, and Send to Assistant", () => { + render("diagrams"); + const bar = container.querySelector("[data-testid='workshop-action-bar']") as HTMLElement; + expect(bar).not.toBeNull(); + const labels = Array.from(bar.querySelectorAll("button")).map((b) => b.textContent?.trim()); + expect(labels).toContain("Save"); + expect(labels).toContain("Export"); + expect(labels).toContain("Send to Assistant"); + }); + + it("uses the two-column grid layout on large screens", () => { + render("diagrams"); + const grid = container.querySelector("[data-testid='workshop-detail-grid']") as HTMLElement; + expect(grid).not.toBeNull(); + expect(grid.className).toContain("lg:grid-cols-"); + }); + + it("surfaces a ?prompt=... query param as a visible chip", () => { + render("wallpapers", "/NEX/content-studio/wallpapers?prompt=forest%20at%20dusk"); + const chip = container.querySelector("[data-testid='workshop-prefilled-prompt']"); + expect(chip).not.toBeNull(); + expect(chip?.textContent).toContain("forest at dusk"); + }); + + it("renders a back button with a 'Back to Studio' aria-label", () => { + render("diagrams"); + const back = container.querySelector("button[aria-label='Back to Studio']"); + expect(back).not.toBeNull(); + }); +}); diff --git a/ui/src/pages/StudioWorkshopDetail.tsx b/ui/src/pages/StudioWorkshopDetail.tsx new file mode 100644 index 00000000..c7a1d8b0 --- /dev/null +++ b/ui/src/pages/StudioWorkshopDetail.tsx @@ -0,0 +1,360 @@ +import { useMemo, type ComponentType } from "react"; +import { ArrowLeft, Save, Download, MessageCircle } from "lucide-react"; +import { useNavigate, useLocation } from "@/lib/router"; +import { useCompany } from "../context/CompanyContext"; +import { cn } from "@/lib/utils"; +import { findWorkshop, type WorkshopDefinition } from "../components/studio/workshops"; +import { DiagramGeneratePanel } from "../components/DiagramGeneratePanel"; +import { IconGeneratePanel } from "../components/IconGeneratePanel"; +import { WallpaperGeneratePanel } from "../components/WallpaperGeneratePanel"; +import { SocialPostPanel } from "../components/SocialPostPanel"; +import { DocumentGeneratePanel } from "../components/DocumentGeneratePanel"; +import { BrandKitPanel } from "../components/BrandKitPanel"; +import { ConvertPanel } from "../components/ConvertPanel"; +import { ThemeSeedInput } from "../components/ThemeSeedInput"; +import { ThemePaletteGrid, type PaletteRole } from "../components/ThemePaletteGrid"; +import { ThemePreviewPanel } from "../components/ThemePreviewPanel"; +import { ThemeExportTabs } from "../components/ThemeExportTabs"; +import { ThemeApplyConfirmDialog } from "../components/ThemeApplyConfirmDialog"; +import { useContentJob } from "../hooks/useContentJob"; +import { useEffect, useState } from "react"; + +interface StudioWorkshopDetailProps { + /** + * Workshop slug. Normally parsed from `/content-studio/:workshopSlug`. + * Phase 10 feeds this in from ContentStudio (which path-matches location, + * because App.tsx does not yet define the sub-route — see plan for + * routing-request to the controller). + */ + slug: string; +} + +/** + * Phase 10 — Studio workshop detail view. + * + * Spec (§6.4): two-column layout on desktop. Left column = parameters + * panel (holds the existing generator component). Right column = preview + * area with an action bar at the bottom (Save / Export / Send to Assistant). + * + * For Phase 10 the existing generator panels already render their own + * side-by-side inputs + preview, so we hand the whole generator to the + * left column and show the action bar underneath. Phase 14 (recipe-aware + * flow) will split generators into explicit params and preview halves. + */ +export function StudioWorkshopDetail({ slug }: StudioWorkshopDetailProps) { + const workshop = findWorkshop(slug); + const navigate = useNavigate(); + const { selectedCompanyId } = useCompany(); + const location = useLocation(); + + // Read ?prompt=... from the URL for StudioPromptBar's intent routing. + // The existing generator panels don't currently accept a pre-filled + // prompt prop, so for Phase 10 we just surface it to the user via a + // read-only chip at the top of the params panel and expect them to + // paste it in. Phase 14 wires prefill into every generator. + const prefilledPrompt = useMemo(() => { + const params = new URLSearchParams(location.search); + return params.get("prompt") ?? ""; + }, [location.search]); + + const handleBack = () => { + navigate("content-studio"); + }; + + const handleSendToAssistant = () => { + const base = "assistant"; + if (prefilledPrompt) { + navigate(`${base}?prompt=${encodeURIComponent(prefilledPrompt)}`); + } else { + navigate(base); + } + }; + + if (!workshop) { + return ; + } + + return ( +
+ + + {prefilledPrompt ? ( +
+ From prompt: + {prefilledPrompt} +
+ ) : null} + +
+
+

+ Parameters +

+ +
+ +
+

+ Preview +

+
+ Preview renders inline inside the generator above. Phase 14 will + separate params from preview into this right column. +
+ +
+
+
+ ); +} + +function WorkshopHeader({ + workshop, + onBack, +}: { + workshop: WorkshopDefinition; + onBack: () => void; +}) { + const Icon = workshop.icon; + return ( +
+ +
+ STUDIO + + {workshop.title} +
+
+ ); +} + +function WorkshopActionBar({ onSendToAssistant }: { onSendToAssistant: () => void }) { + // Save / Export are placeholders in Phase 10 — they're part of the spec + // but wiring them to real backends is out of scope. Clicking them is a + // no-op for now; Phase 14 will connect them to the content-job store. + return ( +
+ + + +
+ ); +} + +function ActionButton({ + label, + icon: Icon, + onClick, + primary, +}: { + label: string; + icon: ComponentType<{ className?: string; strokeWidth?: number | string }>; + onClick?: () => void; + primary?: boolean; +}) { + return ( + + ); +} + +function WorkshopNotFound({ slug, onBack }: { slug: string; onBack: () => void }) { + return ( +
+
+ STUDIO / NOT FOUND +
+

+ Unknown workshop +

+

+ There is no workshop with slug {slug}. +

+ +
+ ); +} + +/** + * Render the existing generator component that corresponds to the given + * workshop. Phase 10 does not rewrite any generator internals. + */ +function WorkshopBody({ + workshop, + companyId, +}: { + workshop: WorkshopDefinition; + companyId: string; +}) { + if (!companyId) { + return ( +

+ Select a company to get started. +

+ ); + } + + switch (workshop.componentKey) { + case "diagram": + return ; + case "icon": + return ; + case "theme": + return ; + case "wallpaper": + return ; + case "document": + return ; + case "brand": + return ; + case "social": + return ; + case "convert": + return ; + default: + return ; + } +} + +function WorkshopPlaceholder({ slug }: { slug: string }) { + return ( +
+
+ Coming soon +
+ No generator is wired up for {slug} yet. +
+ ); +} + +/** + * ThemeWorkshopBody — lifted verbatim from the legacy ContentStudio tab + * body. Phase 10 does not rewrite theme generation; it just relocates + * the existing UI under the Studio workshop detail shell. + */ +function ThemeWorkshopBody({ companyId }: { companyId: string }) { + const themeJob = useContentJob(companyId); + const [showApplyDialog, setShowApplyDialog] = useState(false); + const [seedColor, setSeedColor] = useState("var(--primary)"); + const [themeBundle, setThemeBundle] = useState<{ + palette: PaletteRole[]; + exports: { css: string; tailwind: string; vscode: string; json: string }; + } | null>(null); + + useEffect(() => { + if (themeJob.status === "done" && themeJob.resultAssetId && companyId) { + fetch(`/api/companies/${companyId}/assets/${themeJob.resultAssetId}`) + .then((r) => r.json()) + .then((data) => setThemeBundle(data as typeof themeBundle)) + .catch(() => {}); + } + }, [themeJob.status, themeJob.resultAssetId, companyId]); + + return ( +
+ + + {themeBundle ? ( + <> + + + + + setShowApplyDialog(false)} + onCancel={() => setShowApplyDialog(false)} + /> + + ) : null} +
+ ); +}