diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c3f1a950..b022235b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -26,7 +26,8 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; -import { CompanySkills } from "./pages/CompanySkills"; +import { SkillBrowser } from "./pages/SkillBrowser"; +import { SkillDetail } from "./pages/SkillDetail"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; @@ -127,7 +128,8 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/components/SkillCard.test.tsx b/ui/src/components/SkillCard.test.tsx new file mode 100644 index 00000000..c12ab78e --- /dev/null +++ b/ui/src/components/SkillCard.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SkillCard } from "./SkillCard"; +import type { SkillListItem } from "../api/skillRegistry"; + +// Stub @/lib/router Link as an tag for SSR +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; [key: string]: unknown }) => + {children}, +})); + +const mockSkill: SkillListItem = { + id: "test-source/test-skill", + name: "Test Skill", + description: "A test skill for unit testing", + sourceId: "test-source", + category: "testing", + activeVersionId: null, + removedAt: null, + averageRating: 4.2, + ratingCount: 10, +}; + +describe("SkillCard", () => { + it("renders skill name as a link", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Test Skill"); + expect(html).toContain("skills/detail/"); + }); + + it("renders source badge", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("test-source"); + }); + + it("renders star rating when averageRating is non-null", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("4.2"); + }); + + it("shows Install skill button when not installed", () => { + const html = renderToStaticMarkup( + {}} />, + ); + expect(html).toContain("Install skill"); + }); + + it("shows Update skill button when installed with update", () => { + const html = renderToStaticMarkup( + {}} />, + ); + expect(html).toContain("Update skill"); + }); + + it("shows update badge when hasUpdate is true", () => { + const html = renderToStaticMarkup( + {}} />, + ); + expect(html).toContain("Update"); + expect(html).toContain("amber"); + }); + + it("shows check icon when installed without update", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Installed"); + }); + + it("shows loading state on install button", () => { + const html = renderToStaticMarkup( + {}} isLoading />, + ); + expect(html).toContain("Installing"); + }); +}); diff --git a/ui/src/components/SkillCard.tsx b/ui/src/components/SkillCard.tsx new file mode 100644 index 00000000..38cbaf70 --- /dev/null +++ b/ui/src/components/SkillCard.tsx @@ -0,0 +1,127 @@ +import { Check, Download, RotateCcw, Star } from "lucide-react"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { SkillListItem } from "@/api/skillRegistry"; + +// TODO: hasUpdate detection requires backend enhancement — SkillListItem needs +// a hasUpdate field or the UI needs to compare activeVersionId against latest version. +// For now, hasUpdate is always passed as false from parent components. + +export interface SkillCardProps { + skill: SkillListItem; + isInstalled?: boolean; + hasUpdate?: boolean; + onInstall?: () => void; + onUpdate?: () => void; + onRollback?: () => void; + onUninstall?: () => void; + isLoading?: boolean; + className?: string; +} + +export function SkillCard({ + skill, + isInstalled = false, + hasUpdate = false, + onInstall, + onUpdate, + onRollback, + onUninstall, + isLoading = false, + className, +}: SkillCardProps) { + return ( + + + + {/* Row 1: name link (primary visual anchor) + update badge */} +
+ + {skill.name} + + {hasUpdate && ( + + Update + + )} +
+ + {/* Row 2: description (2-line clamp) */} + {skill.description && ( +

{skill.description}

+ )} + + {/* Row 3: source badge + rating + actions (push right) */} +
+ {skill.sourceId} + {skill.averageRating != null && ( + + + {skill.averageRating.toFixed(1)} + + )} +
+ {isInstalled && onRollback && ( + + + + + Rollback + + )} + {!isInstalled && ( + + )} + {isInstalled && hasUpdate && ( + + )} + {isInstalled && !hasUpdate && ( + + )} +
+
+ +
+
+ ); +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 6cedf357..12e684c0 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -124,6 +124,7 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar"; import { InlineEditor } from "@/components/InlineEditor"; import { PageSkeleton } from "@/components/PageSkeleton"; import { Identity } from "@/components/Identity"; +import { SkillCard } from "@/components/SkillCard"; /* ------------------------------------------------------------------ */ /* Section wrapper */ @@ -1254,6 +1255,76 @@ export function DesignGuide() { + {/* ============================================================ */} + {/* SKILL CARD */} + {/* ============================================================ */} +
+ +
+ {}} + /> + + {}} + onRollback={() => {}} + /> + {}} + isLoading + /> +
+
+
+ {/* ============================================================ */} {/* SEPARATOR */} {/* ============================================================ */} diff --git a/ui/src/pages/SkillBrowser.tsx b/ui/src/pages/SkillBrowser.tsx new file mode 100644 index 00000000..d04e32eb --- /dev/null +++ b/ui/src/pages/SkillBrowser.tsx @@ -0,0 +1,3 @@ +export function SkillBrowser() { + return
SkillBrowser placeholder
; +} diff --git a/ui/src/pages/SkillDetail.tsx b/ui/src/pages/SkillDetail.tsx new file mode 100644 index 00000000..69f7f518 --- /dev/null +++ b/ui/src/pages/SkillDetail.tsx @@ -0,0 +1,3 @@ +export function SkillDetail() { + return
SkillDetail placeholder
; +}