- {formatRate(rate)}
+ {
+ accessorKey: "passRate",
+ header: () =>
Pass Rate
,
+ cell: ({ row }) => {
+ const rate = row.original.passRate
+ const isLow = rate !== null && rate < 0.5
+ return (
+
+ {formatRate(rate)}
+
+ )
+ },
+ sortingFn: (rowA, rowB) => {
+ const a = rowA.original.passRate ?? -1
+ const b = rowB.original.passRate ?? -1
+ return a - b
+ },
+ },
+ {
+ accessorKey: "checks",
+ header: () =>
Checks
,
+ cell: ({ row }) => (
+
+ {row.original.checks}
- )
+ ),
},
- sortingFn: (rowA, rowB) => {
- const a = rowA.original.passRate ?? -1
- const b = rowB.original.passRate ?? -1
- return a - b
+ {
+ accessorKey: "uniqueSessions",
+ header: () =>
Sessions
,
+ cell: ({ row }) => (
+
+ {row.original.uniqueSessions}
+
+ ),
},
- },
- {
- accessorKey: "checks",
- header: () =>
Checks
,
- cell: ({ row }) => (
-
- {row.original.checks}
-
- ),
- },
- {
- accessorKey: "uniqueSessions",
- header: () =>
Sessions
,
- cell: ({ row }) => (
-
- {row.original.uniqueSessions}
-
- ),
- },
- {
- accessorKey: "lastSeen",
- header: "Last Seen",
- cell: ({ row }) => (
-
- {row.original.lastSeen ? (
- <>
-
- {timeAgo(row.original.lastSeen)}
- >
- ) : (
- --
- )}
-
- ),
- sortingFn: (rowA, rowB) => {
- const a = rowA.original.lastSeen ? new Date(rowA.original.lastSeen).getTime() : 0
- const b = rowB.original.lastSeen ? new Date(rowB.original.lastSeen).getTime() : 0
- return a - b
+ {
+ accessorKey: "lastSeen",
+ header: "Last Seen",
+ cell: ({ row }) => (
+
+ {row.original.lastSeen ? (
+ <>
+
+ {timeAgo(row.original.lastSeen)}
+ >
+ ) : (
+ --
+ )}
+
+ ),
+ sortingFn: (rowA, rowB) => {
+ const toEpoch = (v: string | null) => {
+ if (!v) return 0
+ const t = new Date(v).getTime()
+ return Number.isNaN(t) ? 0 : t
+ }
+ const a = toEpoch(rowA.original.lastSeen)
+ const b = toEpoch(rowB.original.lastSeen)
+ return a - b
+ },
},
- },
- {
- accessorKey: "hasEvidence",
- header: "Evidence",
- cell: ({ row }) => (
-
- {row.original.hasEvidence ? "Yes" : "No"}
-
- ),
- },
-]
+ {
+ accessorKey: "hasEvidence",
+ header: "Evidence",
+ cell: ({ row }) => (
+
+ {row.original.hasEvidence ? "Yes" : "No"}
+
+ ),
+ },
+ ]
+}
// ---------- Draggable row ----------
@@ -311,11 +306,13 @@ export function SkillHealthGrid({
totalCount,
statusFilter,
onStatusFilterChange,
+ renderSkillName,
}: {
cards: SkillCard[]
totalCount: number
statusFilter?: SkillHealthStatus | "ALL"
onStatusFilterChange?: (v: SkillHealthStatus | "ALL") => void
+ renderSkillName?: (skill: SkillCard) => React.ReactNode
}) {
const [activeView, setActiveView] = React.useState("all")
const [data, setData] = React.useState
([])
@@ -328,6 +325,8 @@ export function SkillHealthGrid({
pageSize: 20,
})
+ const columns = React.useMemo(() => createColumns(renderSkillName), [renderSkillName])
+
// View counts for tab badges
const viewCounts = React.useMemo(() => ({
all: cards.length,
diff --git a/packages/ui/src/lib/constants.tsx b/packages/ui/src/lib/constants.tsx
new file mode 100644
index 0000000..64ce680
--- /dev/null
+++ b/packages/ui/src/lib/constants.tsx
@@ -0,0 +1,43 @@
+import {
+ AlertTriangleIcon,
+ CheckCircleIcon,
+ CircleDotIcon,
+ HelpCircleIcon,
+ XCircleIcon,
+} from "lucide-react";
+import type { SkillHealthStatus } from "../types";
+
+export const STATUS_CONFIG: Record<
+ SkillHealthStatus,
+ {
+ icon: React.ReactNode;
+ variant: "default" | "secondary" | "destructive" | "outline";
+ label: string;
+ }
+> = {
+ HEALTHY: {
+ icon: ,
+ variant: "outline",
+ label: "Healthy",
+ },
+ WARNING: {
+ icon: ,
+ variant: "secondary",
+ label: "Warning",
+ },
+ CRITICAL: {
+ icon: ,
+ variant: "destructive",
+ label: "Critical",
+ },
+ UNGRADED: {
+ icon: ,
+ variant: "secondary",
+ label: "Ungraded",
+ },
+ UNKNOWN: {
+ icon: ,
+ variant: "secondary",
+ label: "Unknown",
+ },
+};
diff --git a/packages/ui/src/lib/format.ts b/packages/ui/src/lib/format.ts
new file mode 100644
index 0000000..3b68a89
--- /dev/null
+++ b/packages/ui/src/lib/format.ts
@@ -0,0 +1,37 @@
+import type { SkillHealthStatus } from "../types";
+
+export function deriveStatus(passRate: number, checks: number): SkillHealthStatus {
+ if (checks < 5) return "UNGRADED";
+ if (passRate >= 0.8) return "HEALTHY";
+ if (passRate >= 0.5) return "WARNING";
+ return "CRITICAL";
+}
+
+export function formatRate(rate: number | null | undefined): string {
+ if (rate === null || rate === undefined || !Number.isFinite(rate)) return "--";
+ return `${Math.round(rate * 100)}%`;
+}
+
+export function sortByPassRateAndChecks(
+ items: T[],
+): T[] {
+ return [...items].sort((a, b) => {
+ const aRate = a.passRate ?? 1;
+ const bRate = b.passRate ?? 1;
+ if (aRate !== bRate) return aRate - bRate;
+ return b.checks - a.checks;
+ });
+}
+
+export function timeAgo(timestamp: string): string {
+ const ts = new Date(timestamp).getTime();
+ if (Number.isNaN(ts)) return "--";
+ const diff = Math.max(0, Date.now() - ts);
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts
new file mode 100644
index 0000000..15467a9
--- /dev/null
+++ b/packages/ui/src/lib/index.ts
@@ -0,0 +1,3 @@
+export { STATUS_CONFIG } from "./constants";
+export { deriveStatus, formatRate, sortByPassRateAndChecks, timeAgo } from "./format";
+export { cn } from "./utils";
diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/packages/ui/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/local-dashboard/src/components/ui/badge.tsx b/packages/ui/src/primitives/badge.tsx
similarity index 98%
rename from apps/local-dashboard/src/components/ui/badge.tsx
rename to packages/ui/src/primitives/badge.tsx
index b20959d..78e4784 100644
--- a/apps/local-dashboard/src/components/ui/badge.tsx
+++ b/packages/ui/src/primitives/badge.tsx
@@ -2,7 +2,7 @@ import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
diff --git a/apps/local-dashboard/src/components/ui/button.tsx b/packages/ui/src/primitives/button.tsx
similarity index 98%
rename from apps/local-dashboard/src/components/ui/button.tsx
rename to packages/ui/src/primitives/button.tsx
index 226defb..23962a2 100644
--- a/apps/local-dashboard/src/components/ui/button.tsx
+++ b/packages/ui/src/primitives/button.tsx
@@ -1,7 +1,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
diff --git a/apps/local-dashboard/src/components/ui/card.tsx b/packages/ui/src/primitives/card.tsx
similarity index 98%
rename from apps/local-dashboard/src/components/ui/card.tsx
rename to packages/ui/src/primitives/card.tsx
index 9bd5a25..4e5020e 100644
--- a/apps/local-dashboard/src/components/ui/card.tsx
+++ b/packages/ui/src/primitives/card.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
function Card({
className,
diff --git a/apps/local-dashboard/src/components/ui/checkbox.tsx b/packages/ui/src/primitives/checkbox.tsx
similarity index 97%
rename from apps/local-dashboard/src/components/ui/checkbox.tsx
rename to packages/ui/src/primitives/checkbox.tsx
index 824b50f..10fe4df 100644
--- a/apps/local-dashboard/src/components/ui/checkbox.tsx
+++ b/packages/ui/src/primitives/checkbox.tsx
@@ -1,6 +1,6 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
diff --git a/apps/local-dashboard/src/components/ui/collapsible.tsx b/packages/ui/src/primitives/collapsible.tsx
similarity index 100%
rename from apps/local-dashboard/src/components/ui/collapsible.tsx
rename to packages/ui/src/primitives/collapsible.tsx
diff --git a/apps/local-dashboard/src/components/ui/dropdown-menu.tsx b/packages/ui/src/primitives/dropdown-menu.tsx
similarity index 99%
rename from apps/local-dashboard/src/components/ui/dropdown-menu.tsx
rename to packages/ui/src/primitives/dropdown-menu.tsx
index bbfb3d7..123c414 100644
--- a/apps/local-dashboard/src/components/ui/dropdown-menu.tsx
+++ b/packages/ui/src/primitives/dropdown-menu.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
diff --git a/packages/ui/src/primitives/index.ts b/packages/ui/src/primitives/index.ts
new file mode 100644
index 0000000..7dbdd1d
--- /dev/null
+++ b/packages/ui/src/primitives/index.ts
@@ -0,0 +1,55 @@
+export { Badge, badgeVariants } from "./badge";
+export { Button, buttonVariants } from "./button";
+export {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "./card";
+export { Checkbox } from "./checkbox";
+export { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "./dropdown-menu";
+export { Label } from "./label";
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
+export {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "./table";
+export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from "./tabs";
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
diff --git a/apps/local-dashboard/src/components/ui/label.tsx b/packages/ui/src/primitives/label.tsx
similarity index 93%
rename from apps/local-dashboard/src/components/ui/label.tsx
rename to packages/ui/src/primitives/label.tsx
index 74da65c..3e315a6 100644
--- a/apps/local-dashboard/src/components/ui/label.tsx
+++ b/packages/ui/src/primitives/label.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
diff --git a/apps/local-dashboard/src/components/ui/select.tsx b/packages/ui/src/primitives/select.tsx
similarity index 99%
rename from apps/local-dashboard/src/components/ui/select.tsx
rename to packages/ui/src/primitives/select.tsx
index 251f939..ae64936 100644
--- a/apps/local-dashboard/src/components/ui/select.tsx
+++ b/packages/ui/src/primitives/select.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
diff --git a/apps/local-dashboard/src/components/ui/table.tsx b/packages/ui/src/primitives/table.tsx
similarity index 98%
rename from apps/local-dashboard/src/components/ui/table.tsx
rename to packages/ui/src/primitives/table.tsx
index 1017c00..1211a64 100644
--- a/apps/local-dashboard/src/components/ui/table.tsx
+++ b/packages/ui/src/primitives/table.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
diff --git a/apps/local-dashboard/src/components/ui/tabs.tsx b/packages/ui/src/primitives/tabs.tsx
similarity index 99%
rename from apps/local-dashboard/src/components/ui/tabs.tsx
rename to packages/ui/src/primitives/tabs.tsx
index 56c4288..02ca6d8 100644
--- a/apps/local-dashboard/src/components/ui/tabs.tsx
+++ b/packages/ui/src/primitives/tabs.tsx
@@ -3,7 +3,7 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
function Tabs({
className,
diff --git a/apps/local-dashboard/src/components/ui/tooltip.tsx b/packages/ui/src/primitives/tooltip.tsx
similarity index 98%
rename from apps/local-dashboard/src/components/ui/tooltip.tsx
rename to packages/ui/src/primitives/tooltip.tsx
index 96a9ec2..669e205 100644
--- a/apps/local-dashboard/src/components/ui/tooltip.tsx
+++ b/packages/ui/src/primitives/tooltip.tsx
@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
-import { cn } from "@/lib/utils"
+import { cn } from "../lib/utils"
function TooltipProvider({
delay = 0,
diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts
new file mode 100644
index 0000000..a851c63
--- /dev/null
+++ b/packages/ui/src/types.ts
@@ -0,0 +1,87 @@
+// -- UI-only types -----------------------------------------------------------
+
+export type SkillHealthStatus = "HEALTHY" | "WARNING" | "CRITICAL" | "UNGRADED" | "UNKNOWN";
+
+export interface SkillCard {
+ name: string;
+ scope: string | null;
+ passRate: number | null;
+ checks: number;
+ status: SkillHealthStatus;
+ hasEvidence: boolean;
+ uniqueSessions: number;
+ lastSeen: string | null;
+}
+
+// -- Dashboard contract types (re-declared for package independence) ----------
+
+export interface EvalSnapshot {
+ before_pass_rate?: number;
+ after_pass_rate?: number;
+ net_change?: number;
+ improved?: boolean;
+ regressions?: Array>;
+ new_passes?: Array>;
+}
+
+export interface EvolutionEntry {
+ timestamp: string;
+ proposal_id: string;
+ action: string;
+ details: string;
+ eval_snapshot?: EvalSnapshot | null;
+}
+
+export interface UnmatchedQuery {
+ timestamp: string;
+ session_id: string;
+ query: string;
+}
+
+export interface PendingProposal {
+ proposal_id: string;
+ action: string;
+ timestamp: string;
+ details: string;
+ skill_name?: string;
+}
+
+export interface EvidenceEntry {
+ proposal_id: string;
+ target: string;
+ stage: string;
+ timestamp: string;
+ rationale: string | null;
+ confidence: number | null;
+ original_text: string | null;
+ proposed_text: string | null;
+ validation: Record | null;
+ details: string | null;
+ eval_set: Array>;
+}
+
+export interface OrchestrateRunSkillAction {
+ skill: string;
+ action: "evolve" | "watch" | "skip";
+ reason: string;
+ deployed?: boolean;
+ rolledBack?: boolean;
+ alert?: string | null;
+ elapsed_ms?: number;
+ llm_calls?: number;
+}
+
+export interface OrchestrateRunReport {
+ run_id: string;
+ timestamp: string;
+ elapsed_ms: number;
+ dry_run: boolean;
+ approval_mode: "auto" | "review";
+ total_skills: number;
+ evaluated: number;
+ evolved: number;
+ deployed: number;
+ watched: number;
+ skipped: number;
+ skill_actions: OrchestrateRunSkillAction[];
+}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..f44e7a8
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "."
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "index.ts"]
+}
diff --git a/tests/types/ui-contract-parity.test.ts b/tests/types/ui-contract-parity.test.ts
new file mode 100644
index 0000000..6b6dabb
--- /dev/null
+++ b/tests/types/ui-contract-parity.test.ts
@@ -0,0 +1,59 @@
+/**
+ * Compile-time parity guard between @selftune/ui types and dashboard-contract.
+ *
+ * If the canonical types in cli/selftune/dashboard-contract.ts diverge from
+ * the re-declared types in packages/ui, this test will fail to compile.
+ */
+
+import { describe, expect, test } from "bun:test";
+
+import type {
+ EvalSnapshot as Canonical_EvalSnapshot,
+ EvidenceEntry as Canonical_EvidenceEntry,
+ EvolutionEntry as Canonical_EvolutionEntry,
+ OrchestrateRunReport as Canonical_OrchestrateRunReport,
+ OrchestrateRunSkillAction as Canonical_OrchestrateRunSkillAction,
+ PendingProposal as Canonical_PendingProposal,
+ UnmatchedQuery as Canonical_UnmatchedQuery,
+} from "../../cli/selftune/dashboard-contract";
+
+import type {
+ EvalSnapshot,
+ EvidenceEntry,
+ EvolutionEntry,
+ OrchestrateRunReport,
+ OrchestrateRunSkillAction,
+ PendingProposal,
+ UnmatchedQuery,
+} from "../../packages/ui/src/types";
+
+// Assert mutual assignability — fails at compile time if fields diverge.
+type AssertAssignable = T extends U ? (U extends T ? true : false) : false;
+
+// Each assertion must resolve to `true`. A `false` here means the types have drifted.
+const _evalSnapshot: AssertAssignable = true;
+const _evolutionEntry: AssertAssignable = true;
+const _unmatchedQuery: AssertAssignable = true;
+const _pendingProposal: AssertAssignable = true;
+const _evidenceEntry: AssertAssignable = true;
+const _orchestrateRunSkillAction: AssertAssignable<
+ OrchestrateRunSkillAction,
+ Canonical_OrchestrateRunSkillAction
+> = true;
+const _orchestrateRunReport: AssertAssignable<
+ OrchestrateRunReport,
+ Canonical_OrchestrateRunReport
+> = true;
+
+describe("@selftune/ui type parity with dashboard-contract", () => {
+ test("types are mutually assignable (compile-time check)", () => {
+ // If this file compiles, all type assertions above passed.
+ expect(_evalSnapshot).toBe(true);
+ expect(_evolutionEntry).toBe(true);
+ expect(_unmatchedQuery).toBe(true);
+ expect(_pendingProposal).toBe(true);
+ expect(_evidenceEntry).toBe(true);
+ expect(_orchestrateRunSkillAction).toBe(true);
+ expect(_orchestrateRunReport).toBe(true);
+ });
+});