feat(nexus): add WorkshopCard and WorkshopGrid components (phase 10)

Presentational primitives for the Studio home. WorkshopCard renders a
single workshop with title/subtitle/volt icon and fires onSelect on
click. WorkshopGrid lays them out responsively (1/2/3 columns). No
routing concerns here — the ContentStudio page binds onSelect to
navigate().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:15:14 +00:00
parent 397e12a8fd
commit d2dcb1c813
4 changed files with 264 additions and 0 deletions

View file

@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkshopCard } from "./WorkshopCard";
import { WORKSHOPS } from "./workshops";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("WorkshopCard", () => {
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 renderCard(workshopIndex: number, onSelect: (slug: string) => void) {
root = createRoot(container);
const workshop = WORKSHOPS[workshopIndex]!;
act(() => {
root!.render(<WorkshopCard workshop={workshop} onSelect={onSelect} />);
});
return workshop;
}
it("renders the workshop title and subtitle", () => {
renderCard(0, () => {});
expect(container.textContent).toContain("DIAGRAMS");
expect(container.textContent).toContain("Mermaid → rendered SVG");
});
it("renders as a button with an aria-label", () => {
renderCard(0, () => {});
const btn = container.querySelector("button");
expect(btn).not.toBeNull();
expect(btn?.getAttribute("aria-label")).toBe("DIAGRAMS workshop");
expect(btn?.getAttribute("type")).toBe("button");
});
it("has the testid with the workshop slug", () => {
renderCard(0, () => {});
expect(container.querySelector("[data-testid='workshop-card-diagrams']")).not.toBeNull();
});
it("fires onSelect with the workshop slug on click", () => {
const onSelect = vi.fn();
renderCard(3, onSelect); // wallpapers
const btn = container.querySelector("button") as HTMLButtonElement;
act(() => {
btn.click();
});
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith("wallpapers");
});
it("applies the semantic border, radius, and hover bg classes from §6.2", () => {
renderCard(0, () => {});
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.className).toContain("border");
expect(btn.className).toContain("border-border");
expect(btn.className).toContain("rounded-lg");
expect(btn.className).toContain("hover:bg-card");
expect(btn.className).toContain("focus-visible:ring-primary");
});
it("renders an icon element in volt with aria-hidden", () => {
renderCard(0, () => {});
const svg = container.querySelector("svg[aria-hidden='true']") as SVGElement;
expect(svg).not.toBeNull();
// class list should include text-primary (volt accent)
expect(svg.getAttribute("class") ?? "").toContain("text-primary");
});
it("renders the Convert workshop card (fold-in from /convert)", () => {
renderCard(7, () => {});
expect(container.textContent).toContain("CONVERT");
expect(container.textContent).toContain("File format conversion");
});
});

View file

@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import type { WorkshopDefinition } from "./workshops";
interface WorkshopCardProps {
workshop: WorkshopDefinition;
onSelect: (slug: WorkshopDefinition["slug"]) => void;
}
/**
* Phase 10 single card rendered inside WorkshopGrid.
*
* Spec (§6.2 of the layout overhaul):
* - 1px charcoal border, 8px radius, transparent fill idle, near-black
* bg-card on hover (120ms transition).
* - 24px padding, Lucide icon 32x32 volt top-right.
* - Title Inter 700 uppercase 24px white, subtitle Inter 400 14px silver.
* - Click navigates to the workshop detail handled by the parent via
* `onSelect`, which receives the workshop slug.
*/
export function WorkshopCard({ workshop, onSelect }: WorkshopCardProps) {
const Icon = workshop.icon;
return (
<button
type="button"
data-testid={`workshop-card-${workshop.slug}`}
aria-label={`${workshop.title} workshop`}
onClick={() => onSelect(workshop.slug)}
className={cn(
"group relative flex w-full flex-col items-start gap-3 overflow-hidden",
"rounded-lg border border-border bg-transparent p-6 text-left",
"transition-colors duration-[120ms] ease-out",
"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",
)}
>
<Icon
aria-hidden="true"
className="absolute right-6 top-6 h-8 w-8 text-primary"
strokeWidth={1.5}
/>
<h2 className="pr-12 text-[24px] font-bold uppercase leading-tight tracking-tight text-foreground">
{workshop.title}
</h2>
<p className="text-[14px] font-normal text-muted-foreground">
{workshop.subtitle}
</p>
</button>
);
}

View file

@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkshopGrid } from "./WorkshopGrid";
import { WORKSHOPS } from "./workshops";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("WorkshopGrid", () => {
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 renderGrid(onSelect: (slug: string) => void) {
root = createRoot(container);
act(() => {
root!.render(<WorkshopGrid workshops={WORKSHOPS} onSelect={onSelect} />);
});
}
it("renders one card per workshop (8 total for Phase 10)", () => {
renderGrid(() => {});
const cards = container.querySelectorAll("button[data-testid^='workshop-card-']");
expect(cards.length).toBe(8);
});
it("renders the cards in canonical Phase 10 order", () => {
renderGrid(() => {});
const cards = Array.from(
container.querySelectorAll("button[data-testid^='workshop-card-']"),
);
const slugs = cards.map((c) =>
(c.getAttribute("data-testid") ?? "").replace("workshop-card-", ""),
);
expect(slugs).toEqual([
"diagrams",
"icons",
"themes",
"wallpapers",
"documents",
"brand-kits",
"social",
"convert",
]);
});
it("fires onSelect with the correct slug when a card is clicked", () => {
const onSelect = vi.fn();
renderGrid(onSelect);
const convert = container.querySelector(
"button[data-testid='workshop-card-convert']",
) as HTMLButtonElement;
act(() => {
convert.click();
});
expect(onSelect).toHaveBeenCalledWith("convert");
});
it("applies the responsive grid classes from §6.2", () => {
renderGrid(() => {});
const grid = container.querySelector("[data-testid='workshop-grid']") as HTMLElement;
expect(grid.className).toContain("grid-cols-1");
expect(grid.className).toContain("sm:grid-cols-2");
expect(grid.className).toContain("lg:grid-cols-3");
});
it("exposes a list role with a descriptive aria-label", () => {
renderGrid(() => {});
const grid = container.querySelector("[role='list']") as HTMLElement;
expect(grid.getAttribute("aria-label")).toBe("Studio workshops");
});
});

View file

@ -0,0 +1,30 @@
import { WorkshopCard } from "./WorkshopCard";
import type { WorkshopDefinition, WorkshopSlug } from "./workshops";
interface WorkshopGridProps {
workshops: readonly WorkshopDefinition[];
onSelect: (slug: WorkshopSlug) => void;
}
/**
* Phase 10 responsive grid of WorkshopCards for the Studio home.
*
* Spec (§6.2): 3-column grid on >= 1024px, 2-column on >= 640px,
* 1-column below. 16px gap between cards.
*/
export function WorkshopGrid({ workshops, onSelect }: WorkshopGridProps) {
return (
<div
data-testid="workshop-grid"
role="list"
aria-label="Studio workshops"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{workshops.map((workshop) => (
<div key={workshop.slug} role="listitem">
<WorkshopCard workshop={workshop} onSelect={onSelect} />
</div>
))}
</div>
);
}