feat(nexus): add StudioWorkshopDetail page (phase 10)

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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:17:58 +00:00
parent cd75772a6a
commit d432326e7a
2 changed files with 561 additions and 0 deletions

View file

@ -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 }) => (
<div data-testid="mock-diagram-panel">diagram:{companyId}</div>
),
}));
vi.mock("../components/IconGeneratePanel", () => ({
IconGeneratePanel: ({ companyId }: { companyId: string }) => (
<div data-testid="mock-icon-panel">icon:{companyId}</div>
),
}));
vi.mock("../components/WallpaperGeneratePanel", () => ({
WallpaperGeneratePanel: ({ companyId }: { companyId: string }) => (
<div data-testid="mock-wallpaper-panel">wallpaper:{companyId}</div>
),
}));
vi.mock("../components/SocialPostPanel", () => ({
SocialPostPanel: ({ companyId }: { companyId: string }) => (
<div data-testid="mock-social-panel">social:{companyId}</div>
),
}));
vi.mock("../components/DocumentGeneratePanel", () => ({
DocumentGeneratePanel: ({ companyId }: { companyId: string }) => (
<div data-testid="mock-document-panel">document:{companyId}</div>
),
}));
vi.mock("../components/BrandKitPanel", () => ({
BrandKitPanel: ({ companyId }: { companyId: string }) => (
<div data-testid="mock-brand-panel">brand:{companyId}</div>
),
}));
vi.mock("../components/ConvertPanel", () => ({
ConvertPanel: ({ companyId }: { companyId: string }) => (
<div data-testid="mock-convert-panel">convert:{companyId}</div>
),
}));
vi.mock("../components/ThemeSeedInput", () => ({
ThemeSeedInput: () => <div data-testid="mock-theme-seed">seed</div>,
}));
vi.mock("../components/ThemePaletteGrid", () => ({
ThemePaletteGrid: () => <div />,
}));
vi.mock("../components/ThemePreviewPanel", () => ({
ThemePreviewPanel: () => <div />,
}));
vi.mock("../components/ThemeExportTabs", () => ({
ThemeExportTabs: () => <div />,
}));
vi.mock("../components/ThemeApplyConfirmDialog", () => ({
ThemeApplyConfirmDialog: () => <div />,
}));
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<typeof createRoot> | 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(
<MemoryRouter initialEntries={[initialPath]}>
<StudioWorkshopDetail slug={slug} />
</MemoryRouter>,
);
});
}
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();
});
});

View file

@ -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 <WorkshopNotFound slug={slug} onBack={handleBack} />;
}
return (
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
<WorkshopHeader workshop={workshop} onBack={handleBack} />
{prefilledPrompt ? (
<div
data-testid="workshop-prefilled-prompt"
className="mt-4 rounded-md border border-border bg-card px-4 py-3 text-[14px] text-muted-foreground"
>
<span className="font-semibold uppercase tracking-[0.1em] text-primary">From prompt: </span>
{prefilledPrompt}
</div>
) : null}
<div
data-testid="workshop-detail-grid"
className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"
>
<section
aria-label="Workshop parameters"
className="flex flex-col gap-4 rounded-lg border border-border bg-card p-6"
>
<h2 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
Parameters
</h2>
<WorkshopBody workshop={workshop} companyId={selectedCompanyId ?? ""} />
</section>
<section
aria-label="Workshop preview"
className="flex flex-col gap-4 rounded-lg border border-border bg-card p-6"
>
<h2 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
Preview
</h2>
<div
data-testid="workshop-preview-area"
className="flex-1 rounded-md border border-dashed border-border p-6 text-[14px] text-muted-foreground"
>
Preview renders inline inside the generator above. Phase 14 will
separate params from preview into this right column.
</div>
<WorkshopActionBar onSendToAssistant={handleSendToAssistant} />
</section>
</div>
</div>
);
}
function WorkshopHeader({
workshop,
onBack,
}: {
workshop: WorkshopDefinition;
onBack: () => void;
}) {
const Icon = workshop.icon;
return (
<header className="flex items-center gap-4">
<button
type="button"
onClick={onBack}
aria-label="Back to Studio"
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground",
"transition-colors duration-100 hover:text-primary hover:bg-card",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<ArrowLeft className="h-5 w-5" strokeWidth={1.5} />
</button>
<div className="flex items-center gap-2 text-[14px] font-semibold uppercase tracking-[0.1em]">
<span className="text-muted-foreground">STUDIO</span>
<span aria-hidden="true" className="text-muted-foreground">
/
</span>
<span className="text-primary">{workshop.title}</span>
</div>
<Icon aria-hidden="true" className="ml-auto h-6 w-6 text-primary" strokeWidth={1.5} />
</header>
);
}
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 (
<div
data-testid="workshop-action-bar"
className="mt-auto flex items-center justify-end gap-2 border-t border-border pt-4"
>
<ActionButton label="Save" icon={Save} />
<ActionButton label="Export" icon={Download} />
<ActionButton label="Send to Assistant" icon={MessageCircle} onClick={onSendToAssistant} primary />
</div>
);
}
function ActionButton({
label,
icon: Icon,
onClick,
primary,
}: {
label: string;
icon: ComponentType<{ className?: string; strokeWidth?: number | string }>;
onClick?: () => void;
primary?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-[13px] font-semibold uppercase tracking-[0.05em]",
"transition-colors duration-100",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
primary
? "border border-border text-primary hover:bg-card"
: "text-muted-foreground hover:text-primary hover:bg-card",
)}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
<span>{label}</span>
</button>
);
}
function WorkshopNotFound({ slug, onBack }: { slug: string; onBack: () => void }) {
return (
<div
data-testid="workshop-not-found"
className="mx-auto flex w-full max-w-[800px] flex-col items-start gap-4 px-6 py-16"
>
<div className="text-[14px] font-semibold uppercase tracking-[0.1em] text-primary">
STUDIO / NOT FOUND
</div>
<h1 className="text-[32px] font-bold uppercase leading-tight text-foreground">
Unknown workshop
</h1>
<p className="text-[14px] text-muted-foreground">
There is no workshop with slug <code className="text-foreground">{slug}</code>.
</p>
<button
type="button"
onClick={onBack}
className={cn(
"inline-flex items-center gap-2 rounded-md border border-border px-3 py-2",
"text-[13px] font-semibold uppercase tracking-[0.05em] text-primary hover:bg-card",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<ArrowLeft className="h-4 w-4" strokeWidth={1.5} />
<span>Back to Studio</span>
</button>
</div>
);
}
/**
* 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 (
<p className="text-[14px] text-muted-foreground">
Select a company to get started.
</p>
);
}
switch (workshop.componentKey) {
case "diagram":
return <DiagramGeneratePanel companyId={companyId} />;
case "icon":
return <IconGeneratePanel companyId={companyId} />;
case "theme":
return <ThemeWorkshopBody companyId={companyId} />;
case "wallpaper":
return <WallpaperGeneratePanel companyId={companyId} />;
case "document":
return <DocumentGeneratePanel companyId={companyId} />;
case "brand":
return <BrandKitPanel companyId={companyId} />;
case "social":
return <SocialPostPanel companyId={companyId} />;
case "convert":
return <ConvertPanel companyId={companyId} />;
default:
return <WorkshopPlaceholder slug={workshop.slug} />;
}
}
function WorkshopPlaceholder({ slug }: { slug: string }) {
return (
<div
data-testid={`workshop-placeholder-${slug}`}
className="rounded-md border border-dashed border-border p-6 text-[14px] text-muted-foreground"
>
<div className="mb-1 text-[12px] font-semibold uppercase tracking-[0.1em] text-primary">
Coming soon
</div>
No generator is wired up for <code className="text-foreground">{slug}</code> yet.
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-6">
<ThemeSeedInput value={seedColor} onChange={setSeedColor} />
<button
type="button"
className={cn(
"inline-flex items-center justify-center rounded-md bg-primary px-4 py-2",
"text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
disabled={themeJob.status === "running" || themeJob.status === "queued"}
onClick={() => {
setThemeBundle(null);
themeJob.submit("theme-palette", { seedColor });
}}
>
{themeJob.status === "running" || themeJob.status === "queued"
? "Generating..."
: "Generate Palette"}
</button>
{themeBundle ? (
<>
<ThemePaletteGrid palette={themeBundle.palette} />
<ThemePreviewPanel palette={themeBundle.palette} variant="light" />
<ThemeExportTabs exports={themeBundle.exports} />
<button
type="button"
className={cn(
"inline-flex items-center justify-center rounded-md bg-primary px-4 py-2",
"text-sm font-medium text-primary-foreground hover:bg-primary/90",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
onClick={() => setShowApplyDialog(true)}
>
Apply to Nexus
</button>
<ThemeApplyConfirmDialog
open={showApplyDialog}
onConfirm={() => setShowApplyDialog(false)}
onCancel={() => setShowApplyDialog(false)}
/>
</>
) : null}
</div>
);
}