feat(10-01): add SkillCard component, tests, route wiring, and design guide entry

- Create SkillCard.tsx with 3-row layout per UI-SPEC (name link, description, source/rating/actions)
- Create SkillCard.test.tsx with 8 passing unit tests using renderToStaticMarkup
- Create SkillBrowser.tsx and SkillDetail.tsx stub pages
- Update App.tsx: remove CompanySkills import, add skills and skills/detail/:skillId routes
- Add SkillCard to DesignGuide.tsx with 4 variants (default, installed, update available, loading)
This commit is contained in:
Mikkel Georgsen 2026-04-01 02:32:55 +02:00 committed by Nexus Dev
parent bf0a61a9db
commit 52d1dd1e87
6 changed files with 286 additions and 2 deletions

View file

@ -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() {
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="skills" element={<SkillBrowser />} />
<Route path="skills/detail/:skillId" element={<SkillDetail />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />

View file

@ -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 <a> tag for SSR
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; [key: string]: unknown }) =>
<a href={to as string} {...props}>{children}</a>,
}));
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(<SkillCard skill={mockSkill} />);
expect(html).toContain("Test Skill");
expect(html).toContain("skills/detail/");
});
it("renders source badge", () => {
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
expect(html).toContain("test-source");
});
it("renders star rating when averageRating is non-null", () => {
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
expect(html).toContain("4.2");
});
it("shows Install skill button when not installed", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} onInstall={() => {}} />,
);
expect(html).toContain("Install skill");
});
it("shows Update skill button when installed with update", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} isInstalled hasUpdate onUpdate={() => {}} />,
);
expect(html).toContain("Update skill");
});
it("shows update badge when hasUpdate is true", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} isInstalled hasUpdate onUpdate={() => {}} />,
);
expect(html).toContain("Update");
expect(html).toContain("amber");
});
it("shows check icon when installed without update", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} isInstalled />,
);
expect(html).toContain("Installed");
});
it("shows loading state on install button", () => {
const html = renderToStaticMarkup(
<SkillCard skill={mockSkill} onInstall={() => {}} isLoading />,
);
expect(html).toContain("Installing");
});
});

View file

@ -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 (
<Card className={cn("flex flex-col", className)}>
<CardContent className="p-4 flex flex-col gap-2">
{/* Row 1: name link (primary visual anchor) + update badge */}
<div className="flex items-start justify-between gap-2">
<Link
to={`/skills/detail/${encodeURIComponent(skill.id)}`}
className="text-sm font-medium hover:underline"
>
{skill.name}
</Link>
{hasUpdate && (
<Badge
variant="outline"
className="text-xs text-amber-600 border-amber-500 shrink-0"
aria-label="Update available"
>
Update
</Badge>
)}
</div>
{/* Row 2: description (2-line clamp) */}
{skill.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{skill.description}</p>
)}
{/* Row 3: source badge + rating + actions (push right) */}
<div className="flex items-center gap-2 mt-auto pt-2">
<Badge variant="secondary" className="text-xs">{skill.sourceId}</Badge>
{skill.averageRating != null && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
{skill.averageRating.toFixed(1)}
</span>
)}
<div className="ml-auto flex gap-1">
{isInstalled && onRollback && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
onClick={onRollback}
disabled={isLoading}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Rollback</TooltipContent>
</Tooltip>
)}
{!isInstalled && (
<Button
size="sm"
variant="outline"
onClick={onInstall}
disabled={isLoading}
>
<Download className="h-3.5 w-3.5 mr-1" />
{isLoading ? "Installing\u2026" : "Install skill"}
</Button>
)}
{isInstalled && hasUpdate && (
<Button
size="sm"
variant="outline"
onClick={onUpdate}
disabled={isLoading}
>
{isLoading ? "Updating\u2026" : "Update skill"}
</Button>
)}
{isInstalled && !hasUpdate && (
<Button
size="icon-sm"
variant="ghost"
disabled
title="Installed"
>
<Check className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -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() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* SKILL CARD */}
{/* ============================================================ */}
<Section title="Skill Card">
<SubSection title="Variants">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<SkillCard
skill={{
id: "anthropic/code-review",
name: "Code Review",
description: "Automated code review with style, security, and correctness checks.",
sourceId: "anthropic",
category: "engineering",
activeVersionId: null,
removedAt: null,
averageRating: 4.7,
ratingCount: 42,
}}
onInstall={() => {}}
/>
<SkillCard
skill={{
id: "anthropic/planning",
name: "Planning",
description: "Breaks down complex features into actionable tasks.",
sourceId: "anthropic",
category: "productivity",
activeVersionId: "v1.0.0",
removedAt: null,
averageRating: null,
ratingCount: null,
}}
isInstalled
/>
<SkillCard
skill={{
id: "community/docs-writer",
name: "Docs Writer",
description: "Generates clear technical documentation from code and comments.",
sourceId: "community",
category: "docs",
activeVersionId: "v1.1.0",
removedAt: null,
averageRating: 3.9,
ratingCount: 15,
}}
isInstalled
hasUpdate
onUpdate={() => {}}
onRollback={() => {}}
/>
<SkillCard
skill={{
id: "anthropic/code-review",
name: "Code Review",
description: "Automated code review with style, security, and correctness checks.",
sourceId: "anthropic",
category: "engineering",
activeVersionId: null,
removedAt: null,
averageRating: 4.7,
ratingCount: 42,
}}
onInstall={() => {}}
isLoading
/>
</div>
</SubSection>
</Section>
{/* ============================================================ */}
{/* SEPARATOR */}
{/* ============================================================ */}

View file

@ -0,0 +1,3 @@
export function SkillBrowser() {
return <div>SkillBrowser placeholder</div>;
}

View file

@ -0,0 +1,3 @@
export function SkillDetail() {
return <div>SkillDetail placeholder</div>;
}