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:
parent
397e12a8fd
commit
d2dcb1c813
4 changed files with 264 additions and 0 deletions
94
ui/src/components/studio/WorkshopCard.test.tsx
Normal file
94
ui/src/components/studio/WorkshopCard.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
50
ui/src/components/studio/WorkshopCard.tsx
Normal file
50
ui/src/components/studio/WorkshopCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
ui/src/components/studio/WorkshopGrid.test.tsx
Normal file
90
ui/src/components/studio/WorkshopGrid.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
30
ui/src/components/studio/WorkshopGrid.tsx
Normal file
30
ui/src/components/studio/WorkshopGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue