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