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}
+
+
+
+
+
+
+ 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 (
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+ );
+}
+
+function WorkshopNotFound({ slug, onBack }: { slug: string; onBack: () => void }) {
+ return (
+
+
+ STUDIO / NOT FOUND
+
+
+ Unknown workshop
+
+
+ There is no workshop with slug {slug}.
+
+
+
+ Back to Studio
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {
+ setThemeBundle(null);
+ themeJob.submit("theme-palette", { seedColor });
+ }}
+ >
+ {themeJob.status === "running" || themeJob.status === "queued"
+ ? "Generating..."
+ : "Generate Palette"}
+
+ {themeBundle ? (
+ <>
+
+
+
+ setShowApplyDialog(true)}
+ >
+ Apply to Nexus
+
+ setShowApplyDialog(false)}
+ onCancel={() => setShowApplyDialog(false)}
+ />
+ >
+ ) : null}
+
+ );
+}