diff --git a/ui/src/components/studio/WorkshopCard.test.tsx b/ui/src/components/studio/WorkshopCard.test.tsx new file mode 100644 index 00000000..b21d5ce1 --- /dev/null +++ b/ui/src/components/studio/WorkshopCard.test.tsx @@ -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 | 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(); + }); + 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"); + }); +}); diff --git a/ui/src/components/studio/WorkshopCard.tsx b/ui/src/components/studio/WorkshopCard.tsx new file mode 100644 index 00000000..88baff49 --- /dev/null +++ b/ui/src/components/studio/WorkshopCard.tsx @@ -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 ( + + ); +} diff --git a/ui/src/components/studio/WorkshopGrid.test.tsx b/ui/src/components/studio/WorkshopGrid.test.tsx new file mode 100644 index 00000000..852195a5 --- /dev/null +++ b/ui/src/components/studio/WorkshopGrid.test.tsx @@ -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 | 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(); + }); + } + + 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"); + }); +}); diff --git a/ui/src/components/studio/WorkshopGrid.tsx b/ui/src/components/studio/WorkshopGrid.tsx new file mode 100644 index 00000000..3ed80f92 --- /dev/null +++ b/ui/src/components/studio/WorkshopGrid.tsx @@ -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 ( +
+ {workshops.map((workshop) => ( +
+ +
+ ))} +
+ ); +}