feat(12-02): StarRating component, API extensions, DesignGuide entry

- Create StarRating component with interactive/readonly modes, amber stars, size sm/md
- Add PersonalRating type and taskCount/avgCostUsd/lastUsedAt to SkillListItem
- Add getRatings and addRating to skillRegistryApi
- Add Rating System section to DesignGuide with all variants
- Fix SkillCard fixture and DesignGuide examples to include new SkillListItem fields
This commit is contained in:
Mikkel Georgsen 2026-04-01 04:23:04 +02:00 committed by Nexus Dev
parent 6244bcb246
commit 86d4de87e3
4 changed files with 143 additions and 0 deletions

View file

@ -10,6 +10,19 @@ export type SkillListItem = {
removedAt: number | null;
averageRating: number | null;
ratingCount: number | null;
taskCount: number | null;
avgCostUsd: number | null;
lastUsedAt: number | null;
};
export type PersonalRating = {
id: string;
skillId: string;
versionId: string | null;
stars: number;
note: string | null;
createdAt: number;
updatedAt: number;
};
export type SkillVersion = {
@ -43,4 +56,8 @@ export const skillRegistryApi = {
api.post(`${skillPath(skillId)}/rollback`, { versionId, agentSkillsDir }),
remove: (skillId: string) =>
api.delete(skillPath(skillId)),
getRatings: (skillId: string) =>
api.get<PersonalRating[]>(`${skillPath(skillId)}/ratings`),
addRating: (skillId: string, body: { stars: number; versionId?: string; note?: string }) =>
api.post(`${skillPath(skillId)}/ratings`, body),
};

View file

@ -21,6 +21,9 @@ const mockSkill: SkillListItem = {
removedAt: null,
averageRating: 4.2,
ratingCount: 10,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
};
describe("SkillCard", () => {

View file

@ -0,0 +1,65 @@
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface StarRatingProps {
value: number;
onChange?: (v: number) => void;
readonly?: boolean;
size?: "sm" | "md";
}
export function StarRating({
value,
onChange,
readonly = false,
size = "md",
}: StarRatingProps) {
const iconClass = size === "sm" ? "h-3.5 w-3.5" : "h-5 w-5";
const stars = (
<span className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= value;
return (
<button
key={star}
type="button"
aria-label={`Rate ${star} star${star > 1 ? "s" : ""}`}
disabled={readonly}
onClick={() => onChange?.(star)}
className={cn(
"focus-visible:ring-ring focus-visible:ring-[3px] rounded outline-none",
"hover:bg-accent/10",
readonly ? "cursor-default" : "cursor-pointer",
)}
>
<Star
className={cn(
iconClass,
filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
</button>
);
})}
</span>
);
if (readonly) {
return (
<Tooltip>
<TooltipTrigger asChild>
{stars}
</TooltipTrigger>
<TooltipContent>{value} out of 5 stars</TooltipContent>
</Tooltip>
);
}
return stars;
}

View file

@ -127,6 +127,7 @@ import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { SkillCard } from "@/components/SkillCard";
import { GroupBadge } from "@/components/GroupBadge";
import { StarRating } from "@/components/StarRating";
/* ------------------------------------------------------------------ */
/* Section wrapper */
@ -191,6 +192,7 @@ export function DesignGuide() {
{ key: "status", label: "Status", value: "Active" },
{ key: "priority", label: "Priority", value: "High" },
]);
const [starValue, setStarValue] = useState(3);
return (
<div className="space-y-10 max-w-4xl">
@ -1275,6 +1277,9 @@ export function DesignGuide() {
removedAt: null,
averageRating: 4.7,
ratingCount: 42,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
onInstall={() => {}}
/>
@ -1290,6 +1295,9 @@ export function DesignGuide() {
removedAt: null,
averageRating: null,
ratingCount: null,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
isInstalled
onRollback={() => {}}
@ -1307,6 +1315,9 @@ export function DesignGuide() {
removedAt: null,
averageRating: 3.9,
ratingCount: 15,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
isInstalled
hasUpdate
@ -1326,6 +1337,9 @@ export function DesignGuide() {
removedAt: null,
averageRating: 4.2,
ratingCount: 38,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
onInstall={() => {}}
isLoading
@ -1342,6 +1356,9 @@ export function DesignGuide() {
removedAt: null,
averageRating: null,
ratingCount: null,
taskCount: null,
avgCostUsd: null,
lastUsedAt: null,
}}
isInstalled
hasUpdate
@ -1447,6 +1464,47 @@ export function DesignGuide() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* RATING SYSTEM */}
{/* ============================================================ */}
<Section title="Rating System">
<SubSection title="Interactive — no selection">
<TooltipProvider>
<StarRating value={0} onChange={(v) => console.log("rated", v)} />
</TooltipProvider>
</SubSection>
<SubSection title="Interactive — partial selection (controlled)">
<TooltipProvider>
<StarRating value={starValue} onChange={setStarValue} />
</TooltipProvider>
<p className="text-xs text-muted-foreground mt-1">Current value: {starValue}</p>
</SubSection>
<SubSection title="Interactive — full selection">
<TooltipProvider>
<StarRating value={5} onChange={(v) => console.log("rated", v)} />
</TooltipProvider>
</SubSection>
<SubSection title="Read-only display (value=4)">
<TooltipProvider>
<StarRating value={4} readonly />
</TooltipProvider>
</SubSection>
<SubSection title="Size comparison — sm vs md">
<TooltipProvider>
<div className="flex items-center gap-6">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">sm</span>
<StarRating value={3} readonly size="sm" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">md</span>
<StarRating value={3} readonly size="md" />
</div>
</div>
</TooltipProvider>
</SubSection>
</Section>
{/* ============================================================ */}
{/* KEYBOARD SHORTCUTS */}
{/* ============================================================ */}